Merge branch 'worktree-agent-a4b0610bd14276508'

This commit is contained in:
Brandon Thomas 2026-10-03 12:17:22 -04:00
commit 9fb375e4b0
40 changed files with 2657 additions and 198 deletions

View File

@ -921,7 +921,9 @@ fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<Item
/// `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()))?;
@ -930,6 +932,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
// full-frame adjustment layer with a GPU effect stack) and an Advanced 3D comp (`--adv3d`:
// that one only).
let mut project = (*s.project).clone();
project.settings.gpu_acceleration = true;
let comps: Vec<ItemId> = match args.opt("--comp") {
_ if args.flag("--adv3d") => adv3d_bench_comp(&mut project).into_iter().collect(),
Some(_) => s.active_comp_id().into_iter().collect(),
@ -943,8 +946,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 };
@ -969,9 +972,22 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
std::hint::black_box(mk(Backend::Cpu, Some(&cache)));
});
let gcache = LayerCache::default();
// Traffic in steady state: after the frame that fills the layer cache.
std::hint::black_box(mk(Backend::Gpu, Some(&gcache)));
let t0 = gpu.context().transfer_stats();
let gpu_warm = time_ms(n, || {
std::hint::black_box(mk(Backend::Gpu, Some(&gcache)));
});
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();
@ -998,7 +1014,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}"
);
}
}

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 };

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

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)

View File

@ -74,7 +74,8 @@ pub use color_fx::{
pub use color2::{Curve, auto_correct_settings, equalize_tables, luma_clip_points, shadow_highlight_amounts};
pub use color3::OffsetCurve;
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;
@ -914,6 +915,22 @@ 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",
// effectcraft-gpu fx_extra (shapes, bevels)
"ec.generate.circle",
"ec.generate.ellipse",
"ec.transition.iriswipe",
"ec.perspective.bevelalpha",
"ec.perspective.beveledges",
"ec.obsolete.gaussianlegacy",
];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated

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];

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)
}

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,157 @@ 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>>,
/// Already clamped and quantised to this many levels (8/16 bpc), so quantising again is a
/// no-op (see `ops::quantize`).
pub(crate) levels: Option<f32>,
}
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, levels: 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 +246,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 +312,8 @@ 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"),
include_str!("shaders/fx_extra.wgsl"),
]
.concat();
let module =
@ -215,6 +371,8 @@ impl GpuContext {
.chain(crate::fx_stylize::KERNELS)
.chain(crate::fx_noise::KERNELS)
.chain(crate::fx_tone::KERNELS)
.chain(crate::fx_warp::KERNELS)
.chain(crate::fx_extra::KERNELS)
.map(|e| (e, &layout))
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
.map(|(e, layout)| {
@ -274,8 +432,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 +463,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 +482,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 +497,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), levels: None }, 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 +533,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 +591,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 +626,50 @@ impl<'g> Enc<'g> {
pub(crate) 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 +766,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 +822,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`

View File

@ -131,6 +131,8 @@ 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),
_ if crate::fx_extra::IDS.contains(&id) => crate::fx_extra::apply(e, id, ctx, b),
_ => pointwise(e, id, ctx, b),
}
}
@ -162,7 +164,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 +227,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 +260,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 +287,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 +340,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 +466,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 })
}

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 })
}

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)

196
crates/gpu/src/fx_extra.rs Normal file
View File

@ -0,0 +1,196 @@
//! GPU effects, shapes and bevels (kernels in `shaders/fx_extra.wgsl`, every entry point
//! prefixed `fxe_`): Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges and Gaussian Blur
//! (Legacy), with the CPU effects' exact steps. Radii, feathers, light directions and Iris
//! Wipe's polygon are computed on the CPU in f64 as the CPU effect does; the kernels work
//! relative to the shape's centre.
use effectcraft_effects::EffectCtx;
use crate::context::{Enc, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_extra.wgsl`.
pub(crate) const KERNELS: &[&str] = &["fxe_circle", "fxe_ellipse", "fxe_iris", "fxe_alpha", "fxe_bevel_alpha", "fxe_bevel_edges"];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.generate.circle",
"ec.generate.ellipse",
"ec.transition.iriswipe",
"ec.perspective.bevelalpha",
"ec.perspective.beveledges",
"ec.obsolete.gaussianlegacy",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.generate.circle" => circle(e, ctx, b),
"ec.generate.ellipse" => ellipse(e, ctx, b),
"ec.transition.iriswipe" => iris_wipe(e, ctx, b),
"ec.perspective.bevelalpha" => bevel_alpha(e, ctx, b),
"ec.perspective.beveledges" => bevel_edges(e, ctx, b),
"ec.obsolete.gaussianlegacy" => gaussian_legacy(e, ctx, b),
_ => None,
}
}
/// A same-size per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&wgpu::Buffer>) -> Option<GBuf> {
let out = e.scratch(b.img.width, b.img.height);
e.pixels(entry, p, &b.img, aux, &out, data);
b.img = out;
Some(b)
}
fn f4(v: [f64; 4]) -> [f32; 4] {
v.map(|x| x as f32)
}
// ---------------------------------------------------------------- Circle (generate::circle)
fn circle(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let c = b.to_px(pr.v2("center"));
let r = pr.f("radius") * b.scale;
let thick = pr.f("thickness").max(0.0);
let mut f_out = pr.f("feather/feather").max(0.0) * b.scale;
let mut f_in = pr.f("feather/featherInner").max(0.0) * b.scale;
let inner = match pr.e("edge") {
1 => Some(pr.f("edgeRadius") * b.scale),
2 => Some(r - thick * b.scale),
3 => Some(r - thick / 100.0 * r),
4 => {
f_out = pr.f("feather/feather").max(0.0) / 100.0 * r;
f_in = pr.f("feather/featherInner").max(0.0) / 100.0 * r;
Some(r - thick / 100.0 * r)
}
_ => None,
};
let (ro, ri) = match inner {
Some(i) => (r.max(i), Some(r.min(i).max(0.0))),
None => (r, None),
};
let (f_out, f_in) = (f_out.max(0.5), f_in.max(0.5));
let color = pr.color("color");
let mut p = Params::default();
p.u[0] = [crate::fx_generate::gen_mode(pr.e("blendingMode")), ri.is_some() as u32, pr.b("invert") as u32, 0];
p.f[0] = f4([c.0, c.1, ro, ri.unwrap_or(0.0)]);
p.f[1] = [f_out as f32, f_in as f32, pr.f("opacity") as f32 / 100.0, 0.0];
p.f[2] = [color[0], color[1], color[2], 0.0];
run(e, "fxe_circle", &p, b, None, None)
}
// ---------------------------------------------------------------- Ellipse (generate2::ellipse)
fn ellipse(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let c = b.to_px(pr.v2("center"));
let rx = (pr.f("width") * b.scale * 0.5).max(0.5);
let ry = (pr.f("height") * b.scale * 0.5).max(0.5);
let half = (pr.f("thickness") * b.scale * 0.5).max(0.25);
let soft = (pr.f("softness") / 100.0).clamp(0.0, 1.0);
let (inside, outside) = (pr.color("insideColor"), pr.color("outsideColor"));
let mut p = Params::default();
p.u[0][1] = pr.b("compositeOnOriginal") as u32;
p.f[0] = f4([c.0, c.1, rx, ry]);
p.f[1] = [half as f32, soft as f32, 0.0, 0.0];
p.f[2] = [inside[0], inside[1], inside[2], 0.0];
p.f[3] = [outside[0], outside[1], outside[2], 0.0];
run(e, "fxe_ellipse", &p, b, None, None)
}
// ---------------------------------------------------------------- Iris Wipe (transition::iris_wipe)
fn iris_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let outer = pr.f("outerRadius").max(0.0) * b.scale;
if outer <= 0.0 {
return Some(b);
}
let n = pr.f("points").round().clamp(3.0, 64.0) as usize;
let star = pr.b("useInnerRadius");
let inner = if star { pr.f("innerRadius").max(0.0) * b.scale } else { outer };
let rot = pr.f("rotation").to_radians();
let c = b.to_px(pr.v2("center"));
let m = if star { n * 2 } else { n };
let step = std::f64::consts::TAU / m as f64;
let verts: Vec<f32> = (0..m)
.flat_map(|i| {
let r = if star && i % 2 == 1 { inner } else { outer };
let a = rot + i as f64 * step;
[(r * a.sin()) as f32, (-r * a.cos()) as f32]
})
.collect();
let buf = e.data(&verts);
let mut p = Params::default();
p.u[0][1] = m as u32;
p.f[0] = f4([c.0, c.1, rot, step]);
p.f[1] = f4([pr.f("feather") * b.scale, outer.min(inner), outer, 0.0]);
run(e, "fxe_iris", &p, b, None, Some(&buf))
}
// ---------------------------------------------------------------- Bevels (perspective.rs)
/// perspective::light_dir.
fn light_dir(angle_deg: f64, elevation_deg: f64) -> [f64; 3] {
let (a, el) = (angle_deg.to_radians(), elevation_deg.to_radians());
let v = [a.sin() * el.cos(), -a.cos() * el.cos(), el.sin()];
let l = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-12);
v.map(|x| x / l)
}
fn bevel_alpha(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let t = pr.f("edgeThickness") * b.scale;
let inten = pr.f("lightIntensity") as f32;
if t <= 0.0 || inten == 0.0 {
return Some(b);
}
let light = pr.color("lightColor");
let l = light_dir(pr.f("lightAngle"), 45.0);
// The alpha plane's Gaussian (util::gauss_plane: box passes, edges repeated); kernels read x.
let alpha = e.scratch(b.img.width, b.img.height);
e.pixels("fxe_alpha", &Params::default(), &b.img, None, &alpha, None);
let h = gaussian_blur(e, &alpha, t * 0.5, t * 0.5, true);
let mut p = Params::default();
p.f[0] = f4([l[0], l[1], l[2], t * 1.25]);
p.f[1] = [light[0], light[1], light[2], inten];
run(e, "fxe_bevel_alpha", &p, b, Some(&h), None)
}
fn bevel_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let (x0, y0, w, h) = (b.offset[0], b.offset[1], ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
let t = pr.f("edgeThickness").clamp(0.0, 0.5) * w.min(h);
let inten = pr.f("lightIntensity") as f32;
if t <= 0.0 || inten == 0.0 {
return Some(b);
}
let light = pr.color("lightColor");
let l = light_dir(pr.f("lightAngle"), 45.0);
let mut p = Params::default();
p.f[0] = f4([l[0], l[1], l[2], t]);
p.f[1] = [light[0], light[1], light[2], inten];
p.f[2] = f4([x0, y0, w, h]);
run(e, "fxe_bevel_edges", &p, b, None, None)
}
// ---------------------------------------------------------------- Gaussian Blur (Legacy) (obsolete.rs)
fn gaussian_legacy(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let s = ctx.params.f("blurriness").max(0.0) * 0.5 * b.scale;
if s <= 0.0 {
return Some(b);
}
let (kx, ky) = match ctx.params.e("blurDimensions") {
1 => (1.0, 0.0),
2 => (0.0, 1.0),
_ => (1.0, 1.0),
};
if !ctx.adjustment {
b.pad(e, (s * 3.0).ceil() as u32)?;
}
b.img = gaussian_blur(e, &b.img, s * kx, s * ky, ctx.adjustment);
Some(b)
}

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 })
}
@ -83,7 +83,7 @@ fn hl(v: f64) -> [f32; 2] {
}
/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace).
fn gen_mode(i: u32) -> u32 {
pub(crate) fn gen_mode(i: u32) -> u32 {
match effectcraft_effects::gen_mode(i) {
None => 255,
Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32,
@ -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 })
}

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);

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;
}

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)

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
crates/gpu/src/fx_warp.rs Normal file
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)
}

View File

@ -20,9 +20,18 @@
//! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
//! uploaded and read back once. Each family lives in its own module with its own WGSL file
//! (`fx_color`, `fx_distort`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`, `fx_tone`);
//! settings a kernel cannot match fall back to the CPU (`catalog::gpu_supported`, or `None`
//! from the family's `apply`).
//! (`fx_color`, `fx_distort`, `fx_extra`, `fx_generate`, `fx_key`, `fx_noise`, `fx_stylize`,
//! `fx_tone`, `fx_warp`); settings a kernel cannot match fall back to the CPU
//! (`catalog::gpu_supported`, or `None` from the family's `apply`).
//!
//! Working textures come from a pool (`context::Pool`): a frame allocates several full-frame
//! RGBA f32 images per layer, and creating and zeroing those cost more than compositing a small
//! layer. A released texture is reused once every encoder that could still read it has been
//! submitted. 8/16 bpc quantisation after each layer is fused into that layer's composite.
//! [`Backend::Auto`](effectcraft_render::Backend) renders each comp on whichever compositor
//! measured faster for it ([`effectcraft_render::AutoPick`], kept by [`Gpu`]): a light comp
//! that the CPU composites in a millisecond stays there rather than paying for a full-frame
//! readback.
//!
//! GPU particles (`particles`): the stepped particle effects hand their simulation to
//! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle
@ -40,18 +49,20 @@ mod context;
mod effects;
mod fx_color;
mod fx_distort;
mod fx_extra;
mod fx_generate;
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;
@ -65,6 +76,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`),
@ -89,7 +102,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 {
@ -165,6 +178,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 {
@ -186,6 +203,8 @@ mod tests_fx_color;
#[cfg(test)]
mod tests_fx_distort;
#[cfg(test)]
mod tests_fx_extra;
#[cfg(test)]
mod tests_fx_generate;
#[cfg(test)]
mod tests_fx_key;
@ -196,4 +215,6 @@ mod tests_fx_stylize;
#[cfg(test)]
mod tests_fx_tone;
#[cfg(test)]
mod tests_fx_warp;
#[cfg(test)]
mod tests_particles;

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
}
@ -44,6 +44,24 @@ pub fn warp(
opacity: f32,
seed: u32,
accumulate: Option<f32>,
) -> GpuImage {
warp_q(e, dst, src, m, sampling, mode, opacity, seed, accumulate, None)
}
/// [`warp`], clamping and quantising the result to `levels` (8/16 bpc: the comp after a layer,
/// in the same pass).
#[allow(clippy::too_many_arguments)]
pub fn warp_q(
e: &mut Enc,
dst: &GpuImage,
src: &GpuImage,
m: &Mat3,
sampling: Sampling,
mode: BlendMode,
opacity: f32,
seed: u32,
accumulate: Option<f32>,
levels: Option<f32>,
) -> GpuImage {
if src.width == 0 || src.height == 0 || opacity <= 0.0 {
return dst.clone();
@ -79,20 +97,23 @@ pub fn warp(
p.f[0] = r[0];
p.f[1] = r[1];
p.f[2] = r[2];
p.f[3] = [opacity, accumulate.unwrap_or(1.0), 0.0, 0.0];
p.f[3] = [opacity, accumulate.unwrap_or(1.0), levels.unwrap_or(0.0), 0.0];
p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32];
let out = e.image(dst.width, dst.height);
let mut out = e.scratch(dst.width, dst.height);
e.pixels("warp_blend", &p, &img, Some(dst), &out, None);
out.levels = levels;
out
}
/// `Image::blend_from`: blend `src` (same size) onto `dst`.
pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32) -> GpuImage {
/// `Image::blend_from`: blend `src` (same size) onto `dst`, clamping and quantising the result
/// to `levels` when set (see [`warp_q`]).
pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32, levels: Option<f32>) -> GpuImage {
let mut p = Params::default();
p.u[0] = [mode_id(mode), 0, seed, 0];
p.f[0] = [opacity, 0.0, 0.0, 0.0];
let out = e.image(dst.width, dst.height);
p.f[0] = [opacity, levels.unwrap_or(0.0), 0.0, 0.0];
let mut out = e.scratch(dst.width, dst.height);
e.pixels("blend_full", &p, src, Some(dst), &out, None);
out.levels = levels;
out
}
@ -105,21 +126,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 +150,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,17 +161,22 @@ 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
}
/// Clamp and quantise to `levels` steps (8/16 bpc).
pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage {
// Quantising is idempotent: an image a fused kernel already quantised stays as it is.
if img.levels == Some(levels) {
return img.clone();
}
let mut p = Params::default();
p.f[0][0] = levels;
let out = e.image(img.width, img.height);
let mut out = e.scratch(img.width, img.height);
e.pixels("quantize", &p, img, None, &out, None);
out.levels = Some(levels);
out
}
@ -174,7 +200,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
}

View File

@ -0,0 +1,206 @@
// GPU effects (shapes and bevels): see src/fx_extra.rs. Circle, Ellipse, Iris Wipe, Bevel Alpha
// and Bevel Edges, each mirroring the CPU effect operation for operation. Coordinates are pixel
// centres relative to the shape's centre (or the layer's corner), so f32 keeps its precision.
fn fxe_inside(p: vec2<i32>) -> bool {
let d = out_dims();
return p.x < d.x && p.y < d.y;
}
fn fxe_rel(p: vec2<i32>, c: vec2<f32>) -> vec2<f32> {
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5) - c;
}
// ---------------------------------------------------------------- Circle (generate::circle)
// u[0] = (gen mode, has inner, invert); f[0] = (cx, cy, outer radius, inner radius);
// f[1] = (outer feather, inner feather, opacity); f[2] = colour.
@compute @workgroup_size(16, 16)
fn fxe_circle(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxe_inside(p)) {
return;
}
let d = length(fxe_rel(p, P.f[0].xy));
var cov = clamp((P.f[0].z - d) / P.f[1].x + 0.5, 0.0, 1.0);
if (P.u[0].y != 0u) {
cov *= clamp((d - P.f[0].w) / P.f[1].y + 0.5, 0.0, 1.0);
}
if (P.u[0].z != 0u) {
cov = 1.0 - cov;
}
let a = cov * P.f[1].z;
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(P.f[2].xyz * a, a), P.u[0].x));
}
// ---------------------------------------------------------------- Ellipse (generate2::ellipse)
// u[0].y = composite on original; f[0] = (cx, cy, rx, ry); f[1] = (half thickness, softness);
// f[2] = inside colour; f[3] = outside colour.
fn fxe_smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
if (abs(e1 - e0) < 1e-9) {
return select(1.0, 0.0, x < e0);
}
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
@compute @workgroup_size(16, 16)
fn fxe_ellipse(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxe_inside(p)) {
return;
}
let d = fxe_rel(p, P.f[0].xy);
let r = P.f[0].zw;
let q = length(d / r);
var dist = min(r.x, r.y);
if (q >= 1e-9) {
let g = d / (r * r) / q;
dist = abs((q - 1.0) / max(length(g), 1e-9));
}
let half = P.f[1].x;
let soft = P.f[1].y;
let t = dist / half;
var a = clamp(half - dist + 0.5, 0.0, 1.0);
if (soft > 1e-3) {
a = 1.0 - fxe_smoothstep(1.0 - soft, 1.0, t);
}
let k = fxe_smoothstep(0.0, 1.0, t);
let cc = P.f[2].xyz + (P.f[3].xyz - P.f[2].xyz) * k;
let g4 = vec4<f32>(cc * a, a);
if (P.u[0].y != 0u) {
let o = textureLoad(src, p, 0);
textureStore(out, p, g4 + o * (1.0 - g4.w));
} else {
textureStore(out, p, g4);
}
}
// ---------------------------------------------------------------- Iris Wipe (transition::iris_wipe)
// data = polygon vertices relative to the centre; u[0] = (_, vertex count);
// f[0] = (cx, cy, rotation, angle step); f[1] = (feather, radius at the centre, outer radius).
fn fxe_cross(a: vec2<f32>, b: vec2<f32>) -> f32 {
return a.x * b.y - a.y * b.x;
}
@compute @workgroup_size(16, 16)
fn fxe_iris(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxe_inside(p)) {
return;
}
let d = fxe_rel(p, P.f[0].xy);
let r = length(d);
let m = P.u[0].y;
let rot = P.f[0].z;
let step = P.f[0].w;
var rb = P.f[1].y;
if (r >= 1e-9) {
let tau = 6.2831855;
let a0 = atan2(d.x, -d.y) - rot;
let a = a0 - floor(a0 / tau) * tau;
let i = min(u32(floor(a / step)), m - 1u);
let vi = vec2<f32>(data[2u * i], data[2u * i + 1u]);
let j = (i + 1u) % m;
let vj = vec2<f32>(data[2u * j], data[2u * j + 1u]);
let e = vj - vi;
let den = fxe_cross(d / r, e);
rb = select(fxe_cross(vi, e) / den, P.f[1].z, abs(den) < 1e-12);
}
let feather = P.f[1].x;
var k = select(0.0, 1.0, r >= rb);
if (feather > 0.0) {
k = clamp((r - rb) / feather + 0.5, 0.0, 1.0);
}
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// ---------------------------------------------------------------- Bevels (perspective.rs)
// perspective::apply_shade.
fn fxe_shade(px: vec4<f32>, s: f32, light: vec3<f32>) -> vec4<f32> {
if (s > 0.0) {
return vec4<f32>(px.xyz + light * s * px.w, px.w);
}
if (s < 0.0) {
return vec4<f32>(px.xyz * max(1.0 + s, 0.0), px.w);
}
return px;
}
// Plane::alpha (in x).
@compute @workgroup_size(16, 16)
fn fxe_alpha(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxe_inside(p)) {
return;
}
textureStore(out, p, vec4<f32>(textureLoad(src, p, 0).w, 0.0, 0.0, 0.0));
}
// Bevel Alpha: src = the layer, aux = its blurred alpha (x). f[0] = (light direction, normal
// scale k); f[1] = (light colour, intensity).
@compute @workgroup_size(16, 16)
fn fxe_bevel_alpha(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxe_inside(p)) {
return;
}
let px = textureLoad(src, p, 0);
if (px.w <= 0.0) {
textureStore(out, p, px);
return;
}
let dx = (tex_get_clamped(aux, p.x + 1, p.y).x - tex_get_clamped(aux, p.x - 1, p.y).x) * 0.5;
let dy = (tex_get_clamped(aux, p.x, p.y + 1).x - tex_get_clamped(aux, p.x, p.y - 1).x) * 0.5;
let k = P.f[0].w;
let n = normalize(vec3<f32>(-dx * k, -dy * k, 1.0));
let l = P.f[0].xyz;
let s = (dot(n, l) - l.z) * P.f[1].w * 2.0;
textureStore(out, p, fxe_shade(px, s, P.f[1].xyz));
}
// Bevel Edges: f[0] = (light direction, thickness); f[1] = (light colour, intensity);
// f[2] = layer rectangle (x0, y0, w, h).
@compute @workgroup_size(16, 16)
fn fxe_bevel_edges(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!fxe_inside(p)) {
return;
}
let px = textureLoad(src, p, 0);
let rc = P.f[2];
let q = vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5);
let d = vec4<f32>(q.x - rc.x, rc.x + rc.z - q.x, q.y - rc.y, rc.y + rc.w - q.y);
if (d.x < 0.0 || d.y < 0.0 || d.z < 0.0 || d.w < 0.0) {
textureStore(out, p, px);
return;
}
var mi = 0;
var mv = d.x;
for (var i = 1; i < 4; i++) {
if (d[i] < mv) {
mv = d[i];
mi = i;
}
}
if (mv >= P.f[0].w) {
textureStore(out, p, px);
return;
}
var n2 = vec2<f32>(-1.0, 0.0);
if (mi == 1) {
n2 = vec2<f32>(1.0, 0.0);
} else if (mi == 2) {
n2 = vec2<f32>(0.0, -1.0);
} else if (mi == 3) {
n2 = vec2<f32>(0.0, 1.0);
}
let tilt = 0.70710677;
let n = vec3<f32>(n2 * tilt, tilt);
let l = P.f[0].xyz;
let s = (dot(n, l) - l.z) * P.f[1].w * 2.0;
textureStore(out, p, fxe_shade(px, s, P.f[1].xyz));
}

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) {

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;

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));
}

View File

@ -33,7 +33,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if ((flags & 2u) != 0u) {
d = vec4<f32>(0.0);
}
textureStore(out, vec2<i32>(x, y), d);
store_q(vec2<i32>(x, y), d, P.f[3].z);
return;
}
let r0 = P.f[0];
@ -52,7 +52,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if (stencil && (flags & 1u) == 0u) {
d = blend_pixel(mode, d, vec4<f32>(0.0), 0.5);
}
textureStore(out, vec2<i32>(x, y), d);
store_q(vec2<i32>(x, y), d, P.f[3].z);
return;
}
var s = sample(src, sampling, px, py);
@ -60,11 +60,11 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if (s.w > 0.0) {
d = d + s * P.f[3].y;
}
textureStore(out, vec2<i32>(x, y), d);
store_q(vec2<i32>(x, y), d, P.f[3].z);
return;
}
if (s.w <= 0.0 && !stencil) {
textureStore(out, vec2<i32>(x, y), d);
store_q(vec2<i32>(x, y), d, P.f[3].z);
return;
}
let op = P.f[3].x;
@ -75,7 +75,7 @@ fn warp_blend(@builtin(global_invocation_id) gid: vec3<u32>) {
if (mode == 1u || mode == 2u) {
n = hash_noise(u32(x), u32(y), seed);
}
textureStore(out, vec2<i32>(x, y), blend_pixel(mode, d, s, n));
store_q(vec2<i32>(x, y), blend_pixel(mode, d, s, n), P.f[3].z);
}
// Blend `src` (same size) onto `aux` (Image::blend_from).
@ -91,7 +91,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3<u32>) {
let d = textureLoad(aux, p, 0);
var s = textureLoad(src, p, 0);
if (s.w <= 0.0 && !is_stencil(mode)) {
textureStore(out, p, d);
store_q(p, d, P.f[0].y);
return;
}
s = s * P.f[0].x;
@ -99,7 +99,7 @@ fn blend_full(@builtin(global_invocation_id) gid: vec3<u32>) {
if (mode == 1u || mode == 2u) {
n = hash_noise(gid.x, gid.y, P.u[0].z);
}
textureStore(out, p, blend_pixel(mode, d, s, n));
store_q(p, blend_pixel(mode, d, s, n), P.f[0].y);
}
// Track matte: `src` × matte factor of `aux`. u[0].x = 0 alpha, 1 alpha inverted, 2 luma,
@ -179,15 +179,27 @@ fn quantize(@builtin(global_invocation_id) gid: vec3<u32>) {
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let levels = P.f[0].x;
textureStore(out, p, quantize_px(textureLoad(src, p, 0), P.f[0].x));
}
fn quantize_px(s: vec4<f32>, levels: f32) -> vec4<f32> {
let inv = 1.0 / levels;
let s = textureLoad(src, p, 0);
let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv;
var o = vec4<f32>(0.0, 0.0, 0.0, a);
o.x = round_away(clamp(s.x, 0.0, a) * levels) * inv;
o.y = round_away(clamp(s.y, 0.0, a) * levels) * inv;
o.z = round_away(clamp(s.z, 0.0, a) * levels) * inv;
textureStore(out, p, o);
return o;
}
// Store a compositing result, clamped and quantised to `levels` when > 0 (8/16 bpc: the comp
// after a layer, fused into the layer's composite).
fn store_q(p: vec2<i32>, v: vec4<f32>, levels: f32) {
if (levels > 0.0) {
textureStore(out, p, quantize_px(v, levels));
} else {
textureStore(out, p, v);
}
}
// Colour conversion on straight colour (render::color::convert).
@ -959,3 +971,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]);
}

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);

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);

View File

@ -0,0 +1,51 @@
//! GPU shape and bevel effects (Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges, Gaussian
//! Blur (Legacy)) vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use crate::tests::{c, effect_case, n};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn circle_and_ellipse() {
effect_case("ec.generate.circle", &[("center", pt(30.0, 20.0)), ("radius", n(15.0))]);
effect_case("ec.generate.circle", &[("radius", n(18.0)), ("edge", e(2)), ("thickness", n(5.0)), ("feather/feather", n(3.0)), ("blendingMode", e(3))]);
effect_case(
"ec.generate.circle",
&[("radius", n(20.0)), ("edge", e(4)), ("thickness", n(30.0)), ("feather/featherInner", n(10.0)), ("invert", on()), ("opacity", n(60.0))],
);
effect_case("ec.generate.circle", &[("radius", n(12.0)), ("edge", e(1)), ("edgeRadius", n(25.0)), ("color", c(0.2, 0.8, 0.4))]);
effect_case("ec.generate.ellipse", &[("center", pt(35.0, 22.0)), ("width", n(50.0)), ("height", n(30.0)), ("thickness", n(6.0))]);
effect_case("ec.generate.ellipse", &[("width", n(40.0)), ("height", n(60.0)), ("softness", n(40.0)), ("compositeOnOriginal", on())]);
}
#[test]
fn iris_wipe() {
effect_case("ec.transition.iriswipe", &[("outerRadius", n(20.0)), ("center", pt(35.0, 22.0))]);
effect_case(
"ec.transition.iriswipe",
&[("outerRadius", n(25.0)), ("points", n(5.0)), ("useInnerRadius", on()), ("innerRadius", n(10.0)), ("rotation", n(20.0)), ("feather", n(4.0))],
);
}
#[test]
fn bevels_and_legacy_gaussian() {
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(4.0)), ("lightIntensity", n(0.6))]);
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(7.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(5.0)), ("blurDimensions", e(2))]);
}

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", &[]);

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]

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);
}
}

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() {
@ -135,7 +135,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)
}
@ -165,22 +165,22 @@ 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)?;
iso = place(e, r, ctx, layer, b, &iso, *m, 1.0, None)?;
}
iso = place(e, r, ctx, layer, &st.body, &iso, BlendMode::Normal, 1.0)?;
iso = place(e, r, ctx, layer, &st.body, &iso, BlendMode::Normal, 1.0, None)?;
return composite_iso(e, r, ctx, layer, iso, canvas, opacity);
}
let mut tmp = canvas.clone();
if bl.knockout > 0 {
let k = place(e, r, ctx, layer, &st.content, &e.image(w, h), BlendMode::Normal, 1.0)?;
let k = place(e, r, ctx, layer, &st.content, &e.zeros(w, h), BlendMode::Normal, 1.0, None)?;
tmp = ops::knockout(e, &tmp, &k);
}
for (b, m) in &st.passes {
tmp = place(e, r, ctx, layer, b, &tmp, *m, 1.0)?;
tmp = place(e, r, ctx, layer, b, &tmp, *m, 1.0, None)?;
}
tmp = place(e, r, ctx, layer, &st.body, &tmp, layer.blend_mode, 1.0)?;
tmp = place(e, r, ctx, layer, &st.body, &tmp, layer.blend_mode, 1.0, None)?;
*canvas = ops::channel_mix(e, canvas, &tmp, bl.channels, opacity);
Some(())
}
@ -192,7 +192,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, None)?;
let pipe = r.pipe();
// Effects run in the working space, not the linear blending space.
let below = match pipe.from_blend() {
@ -215,47 +215,60 @@ fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canv
fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc<Buf>, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
let matte = r.track_matte(ctx, layer).is_some();
if !matte && !layer.preserve_transparency {
*canvas = place(e, r, ctx, layer, buf, canvas, layer.blend_mode, opacity)?;
// The comp is quantised after the layer (8/16 bpc) in the same pass.
*canvas = place(e, r, ctx, layer, buf, canvas, layer.blend_mode, opacity, r.pipe().levels)?;
return Some(());
}
// Render in isolation, then matte / preserve transparency, then blend.
let iso = place(e, r, ctx, layer, buf, &e.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, None)?;
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)
{
let mo = ctx.opacity(m) as f32;
mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo)?;
mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo, None)?;
}
iso = ops::matte(e, &iso, &mimg, kind);
}
if layer.preserve_transparency {
iso = ops::preserve(e, &iso, canvas);
}
*canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32);
*canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32, r.pipe().levels);
Some(())
}
/// Draw a processed layer buffer onto `target` (transform, motion blur sub-samples).
fn place(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc<Buf>, target: &GpuImage, mode: BlendMode, opacity: f32) -> Option<GpuImage> {
/// Draw a processed layer buffer onto `target` (transform, motion blur sub-samples), quantising
/// the result to `levels` when set.
#[allow(clippy::too_many_arguments)]
fn place(
e: &mut Enc,
r: &Renderer,
ctx: &EvalCtx,
layer: &Layer,
buf: &Arc<Buf>,
target: &GpuImage,
mode: BlendMode,
opacity: f32,
levels: Option<f32>,
) -> Option<GpuImage> {
if buf.img.is_empty() || opacity <= 0.0 {
return Some(target.clone());
}
let src = e.g.upload_buf(buf)?;
let pl = r.placement(ctx, layer, buf);
if let [m] = pl.matrices.as_slice() {
return Some(ops::warp(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None));
return Some(ops::warp_q(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None, levels));
}
let mut acc = e.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));
}
Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed))
Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed, levels))
}

144
crates/render/src/auto.rs Normal file
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();
}
}
}

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 } => {

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};
@ -289,6 +291,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
@ -620,16 +627,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) };
@ -1606,9 +1640,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)
}
@ -1761,6 +1806,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;

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());
}

View File

@ -589,14 +589,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))))
}
}

View File

@ -161,8 +161,14 @@ Half, Third and Quarter resolution render proportionally fewer pixels end to end
Acceleration, the default, or Mercury Software Only; `render.backend`). The CPU renderer is the
reference and keeps rendering layer content (sources, masks, CPU effects, layer styles) into the
layer cache. The render crate defines an `Accelerator` trait; `effectcraft_gpu::Gpu` implements it
on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (GPU when an
accelerator is attached and the project's renderer is the GPU). A GPU frame walks the comp like
on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (an accelerator
attached and the project's renderer the GPU). Auto renders each comp's top-level frames on
whichever compositor measured faster for it (`render::auto::AutoPick`, kept by the accelerator):
per comp, output scale and path (readback or viewer display) it averages CPU and GPU frame times
(warm-up on both sides, the fastest warm-up frame starting the average, single stalls capped),
picks the GPU unless the CPU is clearly faster, and re-measures the other side every 48 frames.
Light comps (a few small layers, which the CPU composites only within their bounds) then stay on
the CPU instead of paying for full-frame passes and a readback. A GPU frame walks the comp like
`draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's
sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur
sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes,
@ -184,16 +190,23 @@ GPU canvas; only the depth of field reads back its 8-byte radius range. Advanced
blend modes, track mattes or Preserve Transparency and environment backgrounds take the CPU's
2D compositing path (their scenes still render through `render_3d`). Wireframe outlines draw on
the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU
effects (`effects::GPU_EFFECTS`, 152 of them: blurs, colour correction, keying incl. Key Light,
mattes, channel, stylize, distortion, transitions, generators, noise, grain and time; see
effects (`effects::GPU_EFFECTS`, 166 of them: blurs, colour correction, keying incl. Key Light,
mattes, channel, stylize, distortion and warps (Warp, Bezier Warp, Smear, Reshape, CC Bend It, CC
Page Turn), Cartoon, bevels, shapes, transitions, generators, noise, grain and time; see
[effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes)
as kernels, in one module per family (`gpu::fx_*` with `shaders/fx_*.wgsl`); consecutive GPU
effects run as one chain with one upload and one readback. Statistics that need the whole frame
(Auto Levels / Contrast / Color, Equalize, Shadow/Highlight, Color Stabilizer, Remove Grain's
noise level) are measured on the CPU from one readback and applied on the GPU; effects reading
other frames (Echo, Posterize Time) upload the frames the host renders. Settings a kernel cannot
match render on the CPU (`effects::catalog::gpu_supported`). Tests render scenes on both paths and compare them
(≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the
match render on the CPU (`effects::catalog::gpu_supported`; e.g. Warp's Fisheye and Twist past
50 % or with a distortion, whose Newton inverse wanders chaotically near the crease). Tests render
scenes on both paths and compare them (≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an
adapter. Working textures (premultiplied RGBA f32) come from a pool: a released texture is reused
once every encoder that could still read it has been submitted, transparent inputs share one
zero texture per size, readback staging buffers are reused, and the 8/16 bpc quantisation after
each layer is fused into the layer's composite kernel; under test, pooled scratch images start
as NaNs (a kernel that skips pixels fails the oracle) and validation errors panic. The desktop viewer builds the
`Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back
(`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the
@ -203,8 +216,9 @@ Playground cannons without interacting forces evolve every particle independentl
backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one
particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the
GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions).
`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up and pixel agreement for every
comp plus an adjustment-layer comp.
`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up, Auto's ms/frame, pixel
agreement and the steady-state upload / readback MB per frame for every comp plus an
adjustment-layer comp.
**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are
`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and

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**: 166 effects also run on the GPU compositor with identical results.
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 306 implemented in full, 0 partial (what is missing is listed).
@ -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 |
@ -200,8 +200,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented |
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Implemented |
| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented |
| Circle | `ec.generate.circle` | 11 | | 32 | Implemented |
| Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented |
| Circle | `ec.generate.circle` | 11 | GPU | 32 | Implemented |
| Ellipse | `ec.generate.ellipse` | 8 | GPU | 32 | Implemented |
| Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented |
| Fill | `ec.generate.fill` | 7 | GPU | 32 | Implemented |
| Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented |
@ -285,7 +285,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Basic 3D | `ec.obsolete.basic3d` | 5 | | 32 | Implemented |
| Basic Text | `ec.obsolete.basictext` | 11 | | 32 | Implemented |
| Color Key | `ec.key.colorkey` | 4 | GPU | 32 | Implemented |
| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | | 32 | Implemented |
| Gaussian Blur (Legacy) | `ec.obsolete.gaussianlegacy` | 2 | GPU | 32 | Implemented |
| Lightning | `ec.obsolete.lightning` | 25 | | 32 | Implemented |
| Luma Key | `ec.key.luma` | 5 | GPU | 32 | Implemented |
| Path Text | `ec.obsolete.pathtext` | 32 | | 32 | Implemented |
@ -304,8 +304,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|---|---|---:|:-:|:-:|---|
| 3D Camera Tracker | `ec.perspective.cameratracker` | 12 | | 32 | Implemented |
| 3D Glasses | `ec.perspective.3dglasses` | 8 | | 32 | Implemented |
| Bevel Alpha | `ec.perspective.bevelalpha` | 4 | | 32 | Implemented |
| Bevel Edges | `ec.perspective.beveledges` | 4 | | 32 | Implemented |
| Bevel Alpha | `ec.perspective.bevelalpha` | 4 | GPU | 32 | Implemented |
| Bevel Edges | `ec.perspective.beveledges` | 4 | GPU | 32 | Implemented |
| CC Cylinder | `ec.perspective.cccylinder` | 12 | | 32 | Implemented |
| CC Environment | `ec.perspective.ccenvironment` | 4 | | 32 | Implemented |
| CC Sphere | `ec.perspective.ccsphere` | 14 | | 32 | Implemented |
@ -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 |
@ -403,7 +403,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented |
| Card Wipe | `ec.transition.cardwipe` | 44 | | 32 | Implemented |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | GPU | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |
| Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented |
| Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented |

View File

@ -10,7 +10,7 @@ How close EffectCraft is to After Effects 2026, feature by feature, and how much
| Unweighted | ≈ 94% (half-credit) / 98% (fractions) |
| P0 / P1 / P2 | ≈ 96% / 92% / 85% (half-credit); 99% / 97% / 96% (fractions) |
| Features done / partial / missing | 80 / 12 / 0 of 92 |
| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 152 run on the GPU |
| **Effects** | **298 of 298** After Effects 2026 effects exist; 42 still simplified ([effects.md](effects.md)); 166 run on the GPU |
| Disabled menu entries left | 0 (the stub list is empty) |
| Remaining work | ≈ 19–22 agent-hours at the pace measured so far (≈ 58 on the conservative audit scale) |
| **Wall-clock estimate** | **≈ 4–5 hours** with five agents in parallel; ≈ 3 hours for 100% of P0 + P1 |
@ -136,11 +136,11 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
| Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) |
| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 |
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (166 run on the GPU since M13.13, EFF-5: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon, Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha / Edges and Gaussian Blur (Legacy) joined the 152 of M13.9; simulations, 3D channel, VR, audio-driven, OCIO, text and the CC light / transition families still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
| Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -204,6 +204,33 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8).
as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the
parent's renderer); text and shape strokes extrude as bevelled meshes in paint order.
## Update: M13.13 GPU performance and the remaining GPU ports
- **Small comps on the GPU (PRV-3)**: the GPU frame allocated (and zero-initialised) several
full-frame RGBA f32 textures per layer; working textures now come from a pool (reused once
every encoder that could read them is submitted), transparent inputs share one zero texture,
readback staging buffers are reused and the 8/16 bpc quantisation after each layer is fused
into the layer's composite. `bench --gpu` on an M4 Pro under load (both binaries run back to
back, median of 15): Lower Third 1920×1080 GPU warm 21.0 → 4.1 ms/frame (viewer 11.1 → 2.6;
Half 10.1 → 3.1), EffectCraft Intro 27.5 → 7.8 (viewer 21.7 → 3.6), 3D Showcase ≈ 95 → ≈ 60
(viewer ≈ 80 → ≈ 40), Adjustment Layers 93 → 36. In steady state the only transfer left is
the frame's own readback (0.0 MB uploads per frame on every demo comp; the adjustment
footprint is now cached and uploaded once).
- **Auto picks per comp**: `Backend::Auto` (Mercury GPU Acceleration) times each comp on both
compositors (`render::AutoPick`: per comp, scale and path; warm-up, capped stalls, re-probe
every 48 frames) and renders on the faster one, in renders and in the viewer. A light comp
stays on the CPU, which composites only the layers' bounds: Lower Third Auto 0.8 ms/frame
(CPU warm 1.7, GPU warm 4.1, which is mostly the 32 MB readback).
- **GPU ports (EFF-5)**: Warp, Bezier Warp, Reshape, Smear, CC Bend It, CC Page Turn, Cartoon,
Color Emboss, Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges and Gaussian Blur (Legacy)
run on the GPU (166 GPU effects); Median, Median (Legacy) and Dust & Scratches take any radius
(a sliding 512-bin histogram per row segment above radius 4); CC Power Pin's Perspective
below 100 % runs on the GPU. All match the CPU oracle within 1/255 (8 bpc) / 1e-3 (32 bpc) on
every tested pixel except: Smear and Reshape allow 0.1 % (f32 point-in-outline tests on
boundary pixels; measured 0); Warp's Fisheye and Twist bent past 50 % or with a Horizontal /
Vertical Distortion render on the CPU (`gpu_supported`), since their Newton inverse wanders
chaotically near the crease and f32 settles on other pixels.
## Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint
- **Advanced 3D on the GPU (PRV-3, 3D-3)**: a whole Advanced 3D run renders on the device