diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 4f2192fc..8aa1381b 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -749,6 +749,43 @@ fn time_ms(n: usize, mut f: impl FnMut()) -> f64 { median(&mut v) } +/// `bench --gpu`'s adjustment-layer comp: a copy of the first comp with a full-frame adjustment +/// layer on top running Gaussian Blur, Levels, Vibrance and Glow (all GPU effects). +fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option { + use effectcraft_engine::effects; + use effectcraft_engine::keyframe::Value; + use effectcraft_engine::project::build::{self, Ids}; + use effectcraft_engine::project::{ItemKind, LayerSource, Solid}; + let (_, base) = p.comps().next().map(|(id, c)| (*id, c.clone()))?; + let mut comp = base.clone(); + let (w, h) = (comp.width, comp.height); + let sid = p.add_item( + "Adjustment (bench)", + effectcraft_engine::color::Label::None, + None, + ItemKind::Solid(Solid { color: [1.0; 3], width: w, height: h, pixel_aspect: 1.0 }), + ); + let mut l = build::layer(p, &comp, "Adjustment (bench)", LayerSource::Solid { item: sid }, (w, h), None); + l.switches.adjustment = true; + for (id, vals) in [ + ("ec.blur.gaussian", vec![("blurriness", Value::Scalar(12.0))]), + ("ec.color.levels", vec![("gamma", Value::Scalar(1.3))]), + ("ec.color.vibrance", vec![("vibrance", Value::Scalar(40.0))]), + ("ec.stylize.glow", vec![("threshold", Value::Scalar(55.0))]), + ] { + let spec = effects::find(id)?; + let mut next = p.next_id; + let mut g = effects::instantiate(spec, &mut Ids(&mut next), spec.name, [w as f64, h as f64]); + p.next_id = next; + for (k, v) in vals { + g.prop_mut(k)?.value = v; + } + l.props.sub_mut("effects")?.children.push(g.into()); + } + comp.layers.insert(0, l); + Some(p.add_item("Adjustment Layers (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into()))) +} + /// `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. @@ -756,19 +793,30 @@ 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()))?; let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize; + // Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a + // full-frame adjustment layer with a GPU effect stack). + let mut project = (*s.project).clone(); let comps: Vec = match args.opt("--comp") { Some(_) => s.active_comp_id().into_iter().collect(), - None => s.project.comps().map(|(id, _)| *id).collect(), + None => { + let mut v: Vec = s.project.comps().map(|(id, _)| *id).collect(); + v.extend(adjustment_bench_comp(&mut project)); + v + } }; + 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}", "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view"); + 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" + ); for cid in comps { - let Some(comp) = s.project.comp(cid) else { continue }; - let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let Some(comp) = project.comp(cid) else { continue }; + let name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0).min(comp.duration.seconds() * 0.5)); for (label, scale) in [("Full", 1.0), ("Half", 0.5)] { let mk = |backend: Backend, cache: Option<&LayerCache>| { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() }); + let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() }); r.expr = s.expr.as_deref(); r.cache = cache; r.accel = Some(&gpu); @@ -789,7 +837,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { std::hint::black_box(mk(Backend::Gpu, Some(&gcache))); }); let view = time_ms(n, || { - let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); + let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() }); r.expr = s.expr.as_deref(); r.cache = Some(&gcache); r.accel = Some(&gpu); @@ -797,7 +845,15 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> { gpu.wait(); }); let size = format!("{}x{}", (comp.width as f64 * scale).round(), (comp.height as f64 * scale).round()); - 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}"); + // Agreement: share of pixels where the GPU frame differs from the CPU reference by + // more than 1/255 on any channel. + let (a, b) = (mk(Backend::Cpu, Some(&cache)), mk(Backend::Gpu, Some(&gcache))); + let off = a.data.iter().zip(&b.data).filter(|(p, q)| (0..4).any(|c| (p[c] - q[c]).abs() > 1.0 / 255.0 + 1e-6)).count(); + 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}%" + ); } } Ok(()) diff --git a/crates/effects/src/blur2.rs b/crates/effects/src/blur2.rs index 529a8dee..3b84d283 100644 --- a/crates/effects/src/blur2.rs +++ b/crates/effects/src/blur2.rs @@ -277,11 +277,9 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64 spans } -fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { - let r = ctx.params.f("blurRadius").max(0.0) * b.scale; - if r < 0.5 { - return b; - } +/// Camera Lens Blur's iris spans at blur radius `r` pixels (shape, rotation, roundness and +/// aspect ratio applied) and the iris' horizontal reach (shared with the GPU kernel). +pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) { let shape = ctx.params.e("irisProperties/irisShape"); let sides = if shape >= 8 { 0 } else { shape + 3 }; let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0); @@ -293,7 +291,15 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { s.2 = (s.2 as f64 * aspect).round() as i64; } } - let reach = r * aspect.max(1.0); + (spans, r * aspect.max(1.0)) +} + +fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return b; + } + let (spans, reach) = camera_lens_spans(ctx, r); let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; if !repeat { b.pad(reach.ceil() as u32 + 1); diff --git a/crates/effects/src/distort2.rs b/crates/effects/src/distort2.rs index 66f038e0..2d52f039 100644 --- a/crates/effects/src/distort2.rs +++ b/crates/effects/src/distort2.rs @@ -414,7 +414,8 @@ fn grid_warp(src: &Image, w: u32, h: u32, nx: usize, ny: usize, dest: &[Pt], src out } -fn parse_mesh(s: &str) -> Vec { +/// Mesh Warp's `mesh` parameter: the vertex offsets (layer pixels), row-major. +pub fn parse_mesh(s: &str) -> Vec<(f64, f64)> { s.split(|c: char| c.is_whitespace() || c == ';') .filter(|t| !t.is_empty()) .filter_map(|t| { diff --git a/crates/effects/src/generate.rs b/crates/effects/src/generate.rs index 511a8fea..09b25a27 100644 --- a/crates/effects/src/generate.rs +++ b/crates/effects/src/generate.rs @@ -75,7 +75,7 @@ pub(crate) const GEN_BLEND_MODES: [&str; 20] = [ "Silhouette Alpha", ]; -fn gen_mode(i: u32) -> Option { +pub fn gen_mode(i: u32) -> Option { Some(match i { 0 => return None, 2 => BlendMode::Add, diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 19a9b6a3..ddde36d7 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [ ]; /// Shaping used for Cell Pattern option `i` (see [`cell_value`]). -fn pattern_kind(i: u32) -> u32 { +pub fn pattern_kind(i: u32) -> u32 { match i { 0..=5 => i, 6 => 1, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 3457e576..c4a47508 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -41,6 +41,7 @@ pub mod ocio_config; pub mod paint; mod perspective; mod perspective2; +pub mod psim; pub mod puppet; pub mod roto; mod sim; @@ -63,11 +64,20 @@ use std::sync::OnceLock; pub use color_fx::{exposure_settings, levels_clip}; pub use color2::Curve; +pub use distort2::parse_mesh; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property}; pub use effectcraft_raster::{AuxChannels, Image}; pub use misc::{INVERT_ALPHA, INVERT_CHANNELS}; +pub use sim::particle_state; +pub use sim3::playground_state; +// CPU helpers the GPU kernels share (effectcraft-gpu). +pub use blur2::camera_lens_spans; +pub use generate::gen_mode; +pub use generate2::pattern_kind as cell_pattern_kind; +pub use noise::GrainLook; +pub use transition::place_layer; /// Effect categories in Effects & Presets order. pub const CATEGORIES: &[&str] = &[ @@ -261,6 +271,11 @@ pub trait EffectHost: Sync { fn aux(&self) -> Option> { None } + /// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the + /// CPU. + fn particles(&self) -> Option<&dyn psim::ParticleSim> { + None + } } /// Extra context the renderer may supply (all optional; `Default` is "nothing known"). @@ -642,6 +657,42 @@ pub const GPU_EFFECTS: &[&str] = &[ "ec.color.exposure", "ec.channel.invert", "ec.distort.transform", + "ec.distort.turbulentdisplace", + "ec.distort.displacementmap", + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.distort.twirl", + "ec.distort.bulge", + "ec.distort.cclens", + "ec.distort.meshwarp", + "ec.stylize.mosaic", + "ec.stylize.findedges", + "ec.stylize.emboss", + // Blur, transition and generate family (effectcraft-gpu `fx_generate`). + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", + // effectcraft-gpu fx_color + "ec.color.colorbalance", + "ec.color.vibrance", + "ec.color.lumetri", + "ec.color.blackwhite", + "ec.color.tritone", + "ec.color.colorama", + "ec.color.channelmixer", + "ec.color.selectivecolor", + "ec.key.linearcolor", + "ec.keying.keylight", ]; /// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated diff --git a/crates/effects/src/noise.rs b/crates/effects/src/noise.rs index 4555d16a..4181c070 100644 --- a/crates/effects/src/noise.rs +++ b/crates/effects/src/noise.rs @@ -41,7 +41,7 @@ pub(crate) const GRAIN_BLEND_MODES: [&str; 5] = ["Film", "Multiply", "Add", "Scr /// The Tweaking / Color / Application / Animation controls Add Grain and Match Grain share /// (twirl-down groups with the same ids in both effects). -pub(crate) struct GrainLook { +pub struct GrainLook { pub intensity: f32, pub channel_intensity: [f32; 3], pub size: f32, @@ -62,7 +62,7 @@ pub(crate) struct GrainLook { } impl GrainLook { - pub(crate) fn from_params(ctx: &EffectCtx) -> GrainLook { + pub fn from_params(ctx: &EffectCtx) -> GrainLook { let pr = ctx.params; let ch = |pre: &str, a: &str, b: &str, c: &str| [a, b, c].map(|k| pr.get(&format!("{pre}{k}")).map(Value::as_f64).unwrap_or(1.0) as f32); let ft = ctx.time * 24.0 * pr.f("animation/animationSpeed"); diff --git a/crates/effects/src/psim.rs b/crates/effects/src/psim.rs new file mode 100644 index 00000000..aabbcdce --- /dev/null +++ b/crates/effects/src/psim.rs @@ -0,0 +1,106 @@ +//! Pluggable particle simulation (GPU particles, M12.7). +//! +//! The stepped particle systems (CC Particle World, CC Particle Systems II and Particle +//! Playground's Cannon without interacting forces) evolve every particle independently: births +//! come from a rate (the same `carry` arithmetic as the CPU step), and each particle integrates +//! its own motion with the same fixed step. An accelerator ([`ParticleSim`], reached through +//! [`crate::EffectHost::particles`]) can therefore simulate all of them in parallel and return +//! the state the CPU's `SimCache` would reach. The CPU stays the oracle: anything the backend +//! declines (interacting forces, caps that would drop births) simulates on the CPU. + +/// CC Particle World / CC Particle Systems II physics (see `sim::Phys`), with the per-step and +/// per-spawn constants already evaluated on the CPU (bit-identical to the CPU's). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EngineDesc { + pub life: f32, + pub producer: [f32; 3], + pub radius: [f32; 3], + /// Animation (0 Explosive … 9 Fractal Uni). + pub anim: u32, + pub speed: f32, + pub gravity: [f32; 3], + /// Normalised direction axis. + pub axis: [f32; 3], + pub extra: f32, + /// Cone Axis spread factor `|tan(extra angle / 2)|` (≤ 20). + pub cone: f32, + /// Velocity damping per step `exp(−drag · dt)`. + pub damp: f32, + pub seed: u32, + /// 2D systems keep z = 0. + pub flat: bool, +} + +/// Particle Playground's Cannon with gravity only (see `sim3::Pg`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CannonDesc { + pub pos: [f32; 2], + pub barrel_angle: f32, + pub barrel_radius: f32, + pub dir_spread: f32, + pub vel: f32, + pub vel_spread: f32, + pub gravity: [f32; 2], + pub grav_spread: f32, + pub seed: u32, + /// Particles leaving `[x0, y0, x1, y1]` are removed. + pub bounds: [f32; 4], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ParticleSystem { + Engine(EngineDesc), + Cannon(CannonDesc), +} + +/// One simulation request: the state after `steps` steps of system `key`. +#[derive(Clone, Debug)] +pub struct SimRequest<'a> { + /// Identifies the system (parameters, seed); checkpoints are reused per key. + pub key: u64, + pub steps: u64, + /// Birth step of every particle id born before `steps` (ascending). + pub births: &'a [u32], + pub system: ParticleSystem, +} + +/// A live particle (ascending ids, as the CPU state keeps them). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SimParticle { + pub p: [f32; 3], + pub v: [f32; 3], + pub age: f32, + pub life: f32, + pub rnd: f32, + pub id: u32, +} + +/// A particle simulation backend (the GPU). +pub trait ParticleSim: Send + Sync { + /// The live particles after `req.steps` steps, or `None` (simulate on the CPU). + fn simulate(&self, req: &SimRequest) -> Option>; +} + +/// Birth step of every particle born in the first `steps` steps at `rate` particles per second +/// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the +/// CPU drops births at its live-particle cap, which an independent simulation can't know). +pub fn births(rate: f64, steps: u64, cap: usize) -> Option> { + let per = rate / SPS; + if per * steps as f64 > cap as f64 + 2.0 { + return None; + } + let mut out = Vec::new(); + let mut carry = 0.0f64; + for i in 0..steps { + carry += per; + let n = carry.floor() as u32; + carry -= n as f64; + for _ in 0..n { + out.push(i as u32); + } + } + (out.len() <= cap).then_some(out) +} + +/// Steps per second of the stepped simulations. +pub const SPS: f64 = crate::sim::SPS; diff --git a/crates/effects/src/sim.rs b/crates/effects/src/sim.rs index 7f90af6b..18beeb76 100644 --- a/crates/effects/src/sim.rs +++ b/crates/effects/src/sim.rs @@ -818,6 +818,60 @@ impl Phys { } } +impl Phys { + /// The backend description (per-step and per-spawn constants evaluated here, in f32). + pub(crate) fn desc(&self) -> crate::psim::EngineDesc { + let mut drag = self.resistance; + if self.anim == 3 { + drag += 3.0 * self.extra.max(0.1); + } + let dt = (1.0 / SPS) as f32; + crate::psim::EngineDesc { + life: self.life, + producer: self.producer, + radius: self.radius, + anim: self.anim, + speed: self.speed, + gravity: self.gravity, + axis: norm3(self.axis), + extra: self.extra, + cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0), + damp: (-drag * dt).exp(), + seed: self.seed, + flat: self.flat, + } + } +} + +/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or +/// `None` (simulate on the CPU). +fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = crate::psim::births(phys.rate, steps, phys.max)?; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PState { + parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(), + carry: 0.0, + next_id: births.len() as u32, + })) +} + +/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`, +/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has +/// one (GPU particles), else from the CPU simulation. For comparing the two. +pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option> { + let (phys, cache, salt) = match id { + "ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1), + "ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2), + _ => return None, + }; + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys)); + Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect()) +} + /// Run a particle system to layer time `t` through `cache`. pub(crate) fn simulate(cache: &SimCache, key: u64, t: f64, phys: &Phys) -> std::sync::Arc { cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i)) @@ -859,10 +913,10 @@ fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, static PW_CACHE: SimCache = SimCache::new(6); -fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Particle World's physics. +fn pw_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; - let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); - let phys = Phys { + Phys { rate: pr.f("birthRate").max(0.0) * 120.0, life: pr.f("longevity").max(0.0) as f32, producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32], @@ -882,11 +936,18 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed, max: 40_000, flat: false, - }; + } +} + +fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let phys = pw_phys(ctx); if phys.rate <= 0.0 && ctx.time <= 0.0 { return b; } - let st = simulate(&PW_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1), ctx.time, &phys); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); @@ -936,13 +997,14 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf { static PS2_CACHE: SimCache = SimCache::new(6); -fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// CC Particle Systems II's physics. +fn ps2_phys(ctx: &EffectCtx) -> Phys { let pr = ctx.params; let lh = ctx.layer_size[1] as f32; let unit = lh.max(1.0); let pos = pr.v2("producer/position"); let dir = (pr.f("physics/direction") as f32).to_radians(); - let phys = Phys { + Phys { rate: pr.f("birthRate").max(0.0) * 60.0, life: pr.f("longevity").max(0.0) as f32, producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0], @@ -958,8 +1020,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed, max: 40_000, flat: true, - }; - let st = simulate(&PS2_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2), ctx.time, &phys); + } +} + +fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let lh = ctx.layer_size[1] as f32; + let unit = lh.max(1.0); + let phys = ps2_phys(ctx); + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2); + let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys)); let kind = pr.e("particle/particleType"); let birth = pr.color("particle/birthColor"); let death = pr.color("particle/deathColor"); diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 5560917a..f7425df7 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -231,6 +231,38 @@ impl Pg { static PG_CACHE: SimCache = SimCache::new(6); +/// Particle Playground cannons with gravity only, simulated by the host's particle backend. +fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option> { + let backend = ctx.env.host?.particles()?; + let steps = steps_at(ctx.time); + let births = crate::psim::births(pg.rate, steps, 60_000)?; + let desc = crate::psim::CannonDesc { + pos: pg.pos, + barrel_angle: pg.barrel_angle, + barrel_radius: pg.barrel_radius, + dir_spread: pg.dir_spread, + vel: pg.vel, + vel_spread: pg.vel_spread, + gravity: pg.gravity, + grav_spread: pg.grav_spread, + seed: pg.seed, + bounds: pg.bounds, + }; + let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Cannon(desc) }; + let parts = backend.simulate(&req)?; + Some(std::sync::Arc::new(PgState { + parts: parts + .into_iter() + .map(|q| { + let born = pg.spawn(q.id); + PgParticle { p: [q.p[0], q.p[1]], v: [q.v[0], q.v[1]], ..born } + }) + .collect(), + carry: 0.0, + next_id: births.len() as u32, + })) +} + fn image_key(img: &Image) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -244,14 +276,15 @@ fn image_key(img: &Image) -> u64 { h.finish() } -fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { +/// Particle Playground's producers and forces. +fn pg_of(ctx: &EffectCtx) -> Pg { let pr = ctx.params; let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians(); let gf = pr.f("gravity/gravityForce") as f32; let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone()); let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None }; - let pg = Pg { + Pg { cannon: pr.b("cannonEnabled"), pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) }, barrel_angle: pr.f("cannon/barrelAngle") as f32, @@ -275,7 +308,34 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { ], seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed, bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0], - }; + } +} + +/// The live particles of a Particle Playground whose cannon is the only producer and force +/// besides gravity, at `ctx.time`: from the host's particle backend when it has one (GPU +/// particles), else from the CPU simulation (`None` for other set-ups). For comparing the two. +pub fn playground_state(ctx: &EffectCtx) -> Option> { + let pg = pg_of(ctx); + let pr = ctx.params; + let grid = pr.b("gridEnabled") && pr.f("grid/particlesAcross").round() > 0.0 && pr.f("grid/particlesDown").round() > 0.0; + let exploder = pr.b("exploderEnabled") && pr.get("layerExploder/explodeLayer").and_then(Value::as_layer).is_some(); + if !pg.cannon || grid || exploder || (pg.repel != 0.0 && pg.repel_radius > 0.0) || pg.wall.is_some() || pg.mapper.is_some() { + return None; + } + let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067); + let st = pg_accelerated(ctx, key, &pg).unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), PgState::default, |st, _| pg.step(st))); + Some( + st.parts + .iter() + .map(|q| crate::psim::SimParticle { p: [q.p[0], q.p[1], 0.0], v: [q.v[0], q.v[1], 0.0], age: 0.0, life: 0.0, rnd: 0.0, id: q.id }) + .collect(), + ) +} + +fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { + let pr = ctx.params; + let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32); + let pg = pg_of(ctx); // Initial particles: Grid and Layer Exploder. let grid_on = pr.b("gridEnabled"); let across = pr.f("grid/particlesAcross").max(0.0).round() as u32; @@ -347,7 +407,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf { } st }; - let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)); + // A cannon with gravity only: every particle moves on its own (GPU particles). + let independent = pg.cannon + && !(grid_on && across > 0 && down > 0) + && exploder.is_none() + && !(pg.repel != 0.0 && pg.repel_radius > 0.0) + && pg.wall.is_none() + && pg.mapper.is_none(); + let gpu = if independent { pg_accelerated(ctx, key, &pg) } else { None }; + let st = gpu.unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st))); let s = b.scale as f32; let sprites: Vec = st .parts diff --git a/crates/effects/src/transition.rs b/crates/effects/src/transition.rs index c41c6449..9701f0c4 100644 --- a/crates/effects/src/transition.rs +++ b/crates/effects/src/transition.rs @@ -30,7 +30,7 @@ fn scale_px(px: &mut Px, k: f32) { /// Resample `other` into `b`'s pixel grid with placement `mode` (Gradient Wipe's Gradient /// Placement, Texturize's Texture Placement): 0 tile from the top-left, 1 centre once, /// 2 stretch to fit. -pub(crate) fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { +pub fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image { let (ls, os) = (ctx.layer_size, other.size); let inv = 1.0 / b.scale.max(1e-9); crate::util::gen_image(b.img.width, b.img.height, |x, y| { diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs new file mode 100644 index 00000000..0c72dd60 --- /dev/null +++ b/crates/gpu/src/classic3d.rs @@ -0,0 +1,233 @@ +//! Classic 3D runs on the GPU (`classic3d.wgsl`): the CPU prepares the planes +//! ([`Renderer::prepare_3d_run`](effectcraft_render::Renderer::prepare_3d_run): layer buffers, +//! homographies, materials, lights, track mattes, bokeh depth of field), the GPU packs the +//! buffers into one atlas texture and runs the per-pixel depth sort, lighting, ray-cast shadows +//! and blending of `render::three_d::compose` in one kernel. + +use std::collections::HashMap; + +use effectcraft_geom::Mat4; +use effectcraft_project::LightKind; +use effectcraft_render::three_d::{LightState, PlaneGeo, Run3d}; + +use crate::context::{Enc, GpuImage, Params}; +use crate::ops::mode_id; + +/// Fragments kept per pixel (`MAX_FRAGS` in the shader). Runs that could stack more planes on +/// one pixel render on the CPU. +const MAX_FRAGS: usize = 32; + +/// Little-endian words for the shader's storage structs. +#[derive(Default)] +struct Words(Vec); + +impl Words { + fn f(&mut self, v: [f32; 4]) { + for x in v { + self.0.extend_from_slice(&x.to_le_bytes()); + } + } + fn u(&mut self, v: [u32; 4]) { + for x in v { + self.0.extend_from_slice(&x.to_le_bytes()); + } + } + fn d(&mut self, v: [f64; 4]) { + self.f(v.map(|x| x as f32)); + } + /// Pad an empty array to one element (`size` bytes) so its binding is valid. + fn at_least(mut self, size: usize) -> Vec { + if self.0.len() < size { + self.0.resize(size, 0); + } + self.0 + } +} + +fn affine_rows(w: &mut Words, m: &Mat4) { + for r in 0..3 { + w.d(m.0[r]); + } +} + +/// Shelf-pack rectangles into an atlas no larger than `max` on a side: (width, height, +/// positions). +fn pack(sizes: &[(u32, u32)], max: u32) -> Option<(u32, u32, Vec<(u32, u32)>)> { + let maxw = sizes.iter().map(|s| s.0).max()?; + if maxw > max { + return None; + } + let area: f64 = sizes.iter().map(|s| s.0 as f64 * s.1 as f64).sum(); + let limit = maxw.max((area.sqrt() * 1.25).ceil() as u32).min(max); + let mut order: Vec = (0..sizes.len()).collect(); + order.sort_by_key(|&i| std::cmp::Reverse(sizes[i].1)); + let mut pos = vec![(0, 0); sizes.len()]; + let (mut x, mut y, mut shelf, mut width) = (0u32, 0u32, 0u32, 0u32); + for i in order { + let (w, h) = sizes[i]; + if x + w > limit { + y += shelf; + x = 0; + shelf = 0; + } + pos[i] = (x, y); + x += w; + shelf = shelf.max(h); + width = width.max(x); + } + let height = y + shelf; + (height <= max).then_some((width.max(1), height.max(1), pos)) +} + +/// Upper bound of the planes drawn on any one pixel: a pixel covered by a set of planes lies in +/// each one's box, so every plane of the set overlaps the box of any member. +fn max_overlap(boxes: &[[i64; 4]]) -> usize { + boxes.iter().map(|a| boxes.iter().filter(|b| a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]).count()).max().unwrap_or(0) +} + +fn light_kind(k: LightKind) -> u32 { + match k { + LightKind::Ambient | LightKind::Environment => 0, + LightKind::Parallel => 1, + LightKind::Point => 2, + LightKind::Spot => 3, + } +} + +fn write_geo(w: &mut Words, g: &PlaneGeo) { + // The homography is defined up to scale: normalise it (by a positive factor, keeping the + // sign tests) into f32's comfortable range. + let m = g.hinv.0.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs())); + let k = if m > 0.0 { 1.0 / m } else { 1.0 }; + for r in 0..3 { + w.d([g.hinv.0[r][0] * k, g.hinv.0[r][1] * k, g.hinv.0[r][2] * k, 0.0]); + } + w.d([g.depth[0], g.depth[1], g.depth[2], 0.0]); + affine_rows(w, &g.world); + w.d([g.normal.x, g.normal.y, g.normal.z, 0.0]); + w.d([g.cam.eye.x, g.cam.eye.y, g.cam.eye.z, if g.cam.ortho { 1.0 } else { 0.0 }]); + let f = g.cam.forward(); + w.d([f.x, f.y, f.z, 0.0]); +} + +fn write_light(w: &mut Words, l: &LightState) { + w.u([light_kind(l.kind), l.falloff, l.casts_shadows as u32, 0]); + w.d([l.pos.x, l.pos.y, l.pos.z, 0.0]); + w.d([l.dir.x, l.dir.y, l.dir.z, 0.0]); + w.f([l.color[0], l.color[1], l.color[2], 0.0]); + w.d([l.cone_half, l.feather, l.radius, l.falloff_distance]); + w.d([l.shadow_darkness, l.shadow_diffusion, 0.0, 0.0]); +} + +const PLANE_BYTES: usize = 12 * 16; +const GEO_BYTES: usize = 10 * 16; +const LIGHT_BYTES: usize = 6 * 16; + +/// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the +/// caller draws it on the CPU). +pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option { + let (w, h) = (canvas.width, canvas.height); + let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect(); + if boxes.is_empty() { + return Some(canvas.clone()); + } + if max_overlap(&boxes) > MAX_FRAGS + || run.planes.iter().any(|p| p.winv.0[3] != [0.0, 0.0, 0.0, 1.0] || p.geos.iter().any(|g| g.world.0[3] != [0.0, 0.0, 0.0, 1.0])) + { + return None; + } + // Plane textures: the layer uploads (cached per buffer; bokeh depth of field is already in + // them). + let mut texs: Vec<(GpuImage, [f64; 2])> = Vec::with_capacity(run.planes.len()); + for p in &run.planes { + texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset)); + } + let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect(); + let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?; + if (aw as u64) * (ah as u64) * 16 > 2 << 30 { + return None; + } + let atlas = e.image(aw, ah); + for ((t, _), &(x, y)) in texs.iter().zip(&pos) { + e.copy_into(t, &atlas, x, y); + } + // Track mattes (shared by the planes of one layer). + let mut mattes: Vec = vec![]; + let mut matte_ix: HashMap = HashMap::new(); + let n_px = w as usize * h as usize; + let mut planes = Words::default(); + let mut geos = Words::default(); + let mut n_geos = 0u32; + for ((p, (t, off)), &(ax, ay)) in run.planes.iter().zip(&texs).zip(&pos) { + let matte = match &p.matte { + Some(m) if m.len() == n_px => { + let key = std::sync::Arc::as_ptr(m) as usize; + let next = matte_ix.len() as u32 + 1; + let ix = *matte_ix.entry(key).or_insert(next); + if ix == next { + mattes.extend_from_slice(m); + } + ix + } + Some(_) => return None, + None => 0, + }; + planes.f([ax as f32, ay as f32, t.width as f32, t.height as f32]); + planes.d([off[0], off[1], p.buf.scale, if p.bicubic { 1.0 } else { 0.0 }]); + planes.d(p.bbox.map(|v| v as f64)); + planes.d(p.bounds); + affine_rows(&mut planes, &p.winv); + planes.f([p.mat.ambient, p.mat.diffuse, p.mat.specular, p.mat.exponent() as f32]); + planes.f([p.mat.metal, p.mat.light_transmission, p.opacity, 0.0]); + let flags = p.draw as u32 | (p.preserve as u32) << 1 | (p.mat.accepts_lights as u32) << 2; + planes.u([n_geos, p.geos.len() as u32, flags, mode_id(p.mode)]); + planes.u([p.mat.casts_shadows, p.mat.accepts_shadows, p.layer_id, p.order as u32]); + planes.u([matte, run.seed ^ p.layer_id, 0, 0]); + for g in &p.geos { + write_geo(&mut geos, g); + } + n_geos += p.geos.len() as u32; + } + if (mattes.len() * 4) as u64 > e.g.device.limits().max_storage_buffer_binding_size { + return None; + } + let mut lights = Words::default(); + for l in &run.lights { + write_light(&mut lights, l); + } + let mut casters = Words::default(); + for c in &run.casters { + casters.0.extend_from_slice(&(*c as u32).to_le_bytes()); + } + let pb = e.bytes(planes.at_least(PLANE_BYTES)); + let gb = e.bytes(geos.at_least(GEO_BYTES)); + let lb = e.bytes(lights.at_least(LIGHT_BYTES)); + let cb = e.bytes(casters.at_least(16)); + 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); + 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) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shelf_packing_fits_and_does_not_overlap() { + let sizes = [(100, 40), (30, 90), (60, 60), (100, 10), (5, 5)]; + let (w, h, pos) = pack(&sizes, 256).unwrap(); + for (i, (&(x, y), &(sw, sh))) in pos.iter().zip(&sizes).enumerate() { + assert!(x + sw <= w && y + sh <= h); + for (j, (&(x2, y2), &(sw2, sh2))) in pos.iter().zip(&sizes).enumerate() { + if i != j { + assert!(x + sw <= x2 || x2 + sw2 <= x || y + sh <= y2 || y2 + sh2 <= y, "{i} and {j} overlap"); + } + } + } + assert!(pack(&[(300, 10)], 256).is_none()); + assert_eq!(max_overlap(&[[0, 0, 10, 10], [5, 5, 15, 15], [20, 20, 30, 30]]), 2); + } +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 46868104..d6f38883 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -26,8 +26,13 @@ const ENTRIES: &[&str] = &[ "shadow_make", "shadow_combine", "pointwise", + "adjust_mix", ]; +/// Entry points that also bind group 1 (four read-only storage buffers; see +/// [`Enc::dispatch_ext`]). +const EXT_ENTRIES: &[&str] = &["classic3d"]; + /// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; @@ -88,6 +93,7 @@ pub struct GpuContext { pub(crate) name: String, pub(crate) max_dim: u32, bgl: wgpu::BindGroupLayout, + bgl_ext: wgpu::BindGroupLayout, pipelines: HashMap<&'static str, wgpu::ComputePipeline>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, @@ -96,6 +102,9 @@ pub struct GpuContext { uploads: Mutex, /// Advanced 3D render pipelines (built on first use). pub(crate) adv3d: std::sync::OnceLock, + /// GPU particle pipeline (built on first use) and simulation checkpoints. + pub(crate) particles: crate::particles::PipesCell, + pub(crate) particle_states: crate::particles::StatesCell, } fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { @@ -135,7 +144,15 @@ impl GpuContext { } } let name = format!("{} ({:?})", info.name, info.backend); - let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl")].concat(); + let src = [ + include_str!("shaders/common.wgsl"), + include_str!("shaders/kernels.wgsl"), + include_str!("shaders/classic3d.wgsl"), + include_str!("shaders/fx_color.wgsl"), + include_str!("shaders/fx_distort.wgsl"), + include_str!("shaders/fx_generate.wgsl"), + ] + .concat(); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }); let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -167,12 +184,32 @@ impl GpuContext { bind_group_layouts: &[Some(&bgl)], immediate_size: 0, }); + let storage = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let bgl_ext = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effectcraft kernels (ext)"), + entries: &[storage(0), storage(1), storage(2), storage(3)], + }); + let layout_ext = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effectcraft kernels (ext)"), + bind_group_layouts: &[Some(&bgl), Some(&bgl_ext)], + immediate_size: 0, + }); let pipelines = ENTRIES .iter() - .map(|e| { + .chain(crate::fx_color::KERNELS) + .chain(crate::fx_distort::KERNELS) + .chain(crate::fx_generate::KERNELS) + .map(|e| (e, &layout)) + .chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext))) + .map(|(e, layout)| { let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(e), - layout: Some(&layout), + layout: Some(layout), module: &module, entry_point: Some(e), compilation_options: Default::default(), @@ -221,6 +258,7 @@ impl GpuContext { name, max_dim, bgl, + bgl_ext, pipelines, display_bgl, display, @@ -228,6 +266,8 @@ impl GpuContext { dummy_buf, uploads: Mutex::new(Uploads::default()), adv3d: std::sync::OnceLock::new(), + particles: Default::default(), + particle_states: Default::default(), }) } @@ -374,6 +414,22 @@ impl<'g> Enc<'g> { out: &GpuImage, data: Option<&wgpu::Buffer>, groups: (u32, u32), + ) { + self.dispatch_ext(entry, p, src, aux, out, data, groups, None); + } + + /// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers. + #[allow(clippy::too_many_arguments)] + pub fn dispatch_ext( + &mut self, + entry: &str, + p: &Params, + src: &GpuImage, + aux: Option<&GpuImage>, + out: &GpuImage, + data: Option<&wgpu::Buffer>, + groups: (u32, u32), + ext: Option<[&wgpu::Buffer; 4]>, ) { let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { @@ -395,11 +451,26 @@ impl<'g> Enc<'g> { wgpu::BindGroupEntry { binding: 4, resource: data.unwrap_or(&g.dummy_buf).as_entire_binding() }, ], }); + let bg_ext = ext.map(|b| { + g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &g.bgl_ext, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: b[0].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: b[1].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: b[2].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: b[3].as_entire_binding() }, + ], + }) + }); let enc = self.encoder(); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None }); pass.set_pipeline(pipe); pass.set_bind_group(0, &bg, &[]); + if let Some(b) = &bg_ext { + pass.set_bind_group(1, b, &[]); + } pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1); } self.pending += 1; @@ -525,7 +596,33 @@ impl<'g> Enc<'g> { for x in v { bytes.extend_from_slice(&x.to_le_bytes()); } - while bytes.len() < 16 { + self.bytes(bytes) + } + + /// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image + /// through `data`: (buffer, row length in pixels). + pub fn image_rows(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) { + let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("image rows"), + size: row as u64 * img.height as u64, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + self.encoder().copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { texture: &img.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyBufferInfo { + buffer: &buf, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(img.height) }, + }, + wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, + ); + (buf, row / 16) + } + + /// A read-only storage buffer holding `bytes` (padded to 16 bytes). + pub fn bytes(&self, mut bytes: Vec) -> wgpu::Buffer { + while bytes.len() < 16 || !bytes.len().is_multiple_of(4) { bytes.push(0); } self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index fdc317b3..2ef75c48 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -16,19 +16,19 @@ pub fn supports(id: &str) -> bool { } /// A layer buffer on the GPU (see [`Buf`]). -struct GBuf { - img: GpuImage, - offset: [f64; 2], - scale: f64, +pub(crate) struct GBuf { + pub img: GpuImage, + pub offset: [f64; 2], + pub scale: f64, } impl GBuf { - fn to_px(&self, p: [f64; 2]) -> (f64, f64) { + pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) { (p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1]) } /// Buf::pad: grow by `pad` transparent pixels on every side. - fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { + pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> { if pad == 0 { return Some(()); } @@ -62,6 +62,48 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option Some(Buf { img, offset: b.offset, scale: b.scale }) } +/// An effect stack running on a GPU-resident image (adjustment layers): GPU chains stay on +/// the device; CPU effects read the image back, run, and upload the result. +pub(crate) struct GpuFx<'e, 'g> { + e: &'e mut Enc<'g>, + b: Option, + levels: Option, +} + +impl<'e, 'g> GpuFx<'e, 'g> { + pub fn new(e: &'e mut Enc<'g>, img: GpuImage, offset: [f64; 2], scale: f64, levels: Option) -> Self { + GpuFx { e, b: Some(GBuf { img, offset, scale }), levels } + } + + /// The result: (image, offset, scale); `None` when a step failed. + pub fn finish(self) -> Option<(GpuImage, [f64; 2], f64)> { + self.b.map(|b| (b.img, b.offset, b.scale)) + } +} + +impl effectcraft_render::FxTarget for GpuFx<'_, '_> { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + let Some(b) = &self.b else { return false }; + let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale }; + for step in steps { + let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false }; + cur = next; + if let Some(l) = self.levels { + cur.img = ops::quantize(self.e, &cur.img, l); + } + } + self.b = Some(cur); + true + } + + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) { + let Some(b) = self.b.take() else { return }; + let Some(img) = self.e.download(&b.img) else { return }; + let out = f(Buf { img, offset: b.offset, scale: b.scale }); + self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale }); + } +} + fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { match id { "ec.blur.gaussian" => gaussian(e, ctx, b), @@ -72,6 +114,9 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { "ec.distort.transform" => transform(e, ctx, b), "ec.color.curves" => curves(e, ctx, b), _ if id.starts_with("ec.control.") => Some(b), + _ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b), + _ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b), + _ if crate::fx_generate::IDS.contains(&id) => crate::fx_generate::apply(e, id, ctx, b), _ => pointwise(e, id, ctx, b), } } @@ -79,7 +124,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { // ---------------------------------------------------------------- blurs /// raster::blur::box_radii. -fn box_radii(sigma: f64, n: usize) -> Vec { +pub(crate) fn box_radii(sigma: f64, n: usize) -> Vec { if sigma <= 0.0 { return vec![0; n]; } @@ -95,7 +140,7 @@ fn box_radii(sigma: f64, n: usize) -> Vec { } /// Horizontal passes with radii `rx`, then vertical passes with radii `ry` (raster::blur). -fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage { +pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage { let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { @@ -111,7 +156,7 @@ fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: b } /// raster::gaussian_blur (3 box passes per axis). -fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { +pub(crate) fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage { let rx = if sx > 0.05 { box_radii(sx, 3) } else { vec![] }; let ry = if sy > 0.05 { box_radii(sy, 3) } else { vec![] }; box_passes(e, img, &rx, &ry, repeat) diff --git a/crates/gpu/src/fx_color.rs b/crates/gpu/src/fx_color.rs new file mode 100644 index 00000000..824f9a50 --- /dev/null +++ b/crates/gpu/src/fx_color.rs @@ -0,0 +1,396 @@ +//! GPU effects, colour family (kernels in `shaders/fx_color.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. +//! +//! Colour Balance, Vibrance, Black & White, Tritone, Colorama, Channel Mixer, Selective Color +//! and Linear Color Key are one per-pixel pass (`fxc_point`); Lumetri Color is a per-pixel pass +//! plus, with Sharpen, a luminance plane blurred like `util::gauss_plane`; Key Light runs its +//! matte pipeline (pre-blur, clip, rollback, despot, shrink / grow, softness, edge band) as +//! plane passes. Key Light with an Inside / Outside mask, or Hard Colour replacement after +//! Screen Softness, and Colorama with Interpolate Palette off run on the CPU. + +use effectcraft_color::{luminance, rgb_to_hsl}; +use effectcraft_effects::EffectCtx; + +use crate::context::{Enc, GpuImage, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_color.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "fxc_point", + "fxc_lumetri", + "fxc_luma", + "fxc_sharpen", + "fxc_morph", + "fxc_lerp", + "fxc_kl_raw", + "fxc_kl_rollback", + "fxc_kl_pack", + "fxc_kl_band", + "fxc_kl_edge", + "fxc_kl_final", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.color.colorbalance", + "ec.color.vibrance", + "ec.color.lumetri", + "ec.color.blackwhite", + "ec.color.tritone", + "ec.color.colorama", + "ec.color.channelmixer", + "ec.color.selectivecolor", + "ec.key.linearcolor", + "ec.keying.keylight", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.color.lumetri" => lumetri(e, ctx, b), + "ec.keying.keylight" => key_light(e, ctx, b), + _ => point(e, id, ctx, b), + } +} + +fn rgb(c: [f32; 4]) -> [f32; 4] { + [c[0], c[1], c[2], 0.0] +} + +/// Same selective colour ids as `color2::SC_IDS`. +const SC_IDS: [[&str; 4]; 9] = [ + ["redsCyan", "redsMagenta", "redsYellow", "redsBlack"], + ["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"], + ["greensCyan", "greensMagenta", "greensYellow", "greensBlack"], + ["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"], + ["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"], + ["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"], + ["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"], + ["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"], + ["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"], +]; + +fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| pr.f(k) as f32; + let mut p = Params::default(); + let mut data = None; + match id { + "ec.color.colorbalance" => { + let g = |k: &str| f(k) / 100.0; + p.u[0] = [1, pr.b("preserveLuminosity") as u32, 0, 0]; + p.f[0] = [g("shadowRed"), g("shadowGreen"), g("shadowBlue"), 0.0]; + p.f[1] = [g("midRed"), g("midGreen"), g("midBlue"), 0.0]; + p.f[2] = [g("hiRed"), g("hiGreen"), g("hiBlue"), 0.0]; + } + "ec.color.vibrance" => { + p.u[0][0] = 2; + p.f[0] = [f("vibrance") / 100.0, f("saturation") / 100.0, 0.0, 0.0]; + } + "ec.color.blackwhite" => { + let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0); + let tc = pr.color("tintColor"); + let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]); + p.u[0] = [3, pr.b("tint") as u32, 0, 0]; + p.f[0] = [w[0], w[1], w[2], w[3]]; + p.f[1] = [w[4], w[5], 0.0, 0.0]; + p.f[2] = [th, ts, 0.0, 0.0]; + } + "ec.color.tritone" => { + p.u[0][0] = 4; + p.f[0] = rgb(pr.color("highlights")); + p.f[1] = rgb(pr.color("midtones")); + p.f[2] = rgb(pr.color("shadows")); + p.f[3][0] = 1.0 - f("blend") / 100.0; + } + "ec.color.colorama" => { + let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool()); + // The posterized palette is discontinuous: phases on a step (common with 8 bpc + // input) can round to the other side in the GPU's float maths, so it runs on the CPU. + if !smooth { + return None; + } + p.u[0] = [5, pr.e("inputPhase/mode"), smooth as u32, 0]; + p.f[0] = [pr.f("outputCycle/cycles").max(0.0) as f32, f("inputPhase/phaseShift") / 360.0, 1.0 - f("blend") / 100.0, 0.0]; + } + "ec.color.channelmixer" => { + let g = |k: &str| f(k) / 100.0; + p.u[0] = [6, pr.b("monochrome") as u32, 0, 0]; + p.f[0] = [g("rr"), g("rg"), g("rb"), g("rc")]; + p.f[1] = [g("gr"), g("gg"), g("gb"), g("gc")]; + p.f[2] = [g("br"), g("bg"), g("bb"), g("bc")]; + } + "ec.color.selectivecolor" => { + let adj: Vec = SC_IDS.iter().flat_map(|ids| ids.map(|k| f(k) / 100.0)).collect(); + if adj.iter().all(|v| *v == 0.0) { + return Some(b); + } + p.u[0] = [7, (pr.e("method") == 0) as u32, 0, 0]; + data = Some(e.data(&adj)); + } + "ec.key.linearcolor" => { + let view = pr.e("view"); + if view == 1 { + return Some(b); + } + let k = pr.color("keyColor"); + let s = k[0] + k[1] + k[2]; + let kc = if s <= 1e-6 { [1.0 / 3.0, 1.0 / 3.0] } else { [k[0] / s, k[1] / s] }; + let (kh, _, _) = rgb_to_hsl(k[0], k[1], k[2]); + p.u[0] = [8, pr.e("matchColors"), (pr.e("keyOperation") == 1) as u32, view]; + p.f[0] = [k[0], k[1], k[2], f("tolerance") / 100.0]; + p.f[1] = [f("softness") / 100.0, kc[0], kc[1], kh]; + } + _ => return None, + } + let out = e.image(b.img.width, b.img.height); + e.pixels("fxc_point", &p, &b.img, None, &out, data.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// color3::tint_offset. +fn tint_offset(c: [f32; 4]) -> [f32; 3] { + let l = (c[0] + c[1] + c[2]) / 3.0; + [c[0] - l, c[1] - l, c[2] - l] +} + +fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let f = |k: &str| (pr.f(k) / 100.0) as f32; + let mut d: Vec = vec![ + f("basicCorrection/whiteBalance/temperature"), + f("basicCorrection/whiteBalance/tint"), + 2f32.powf(pr.f("basicCorrection/tone/exposure") as f32), + f("basicCorrection/tone/contrast"), + f("basicCorrection/tone/highlights"), + f("basicCorrection/tone/shadows"), + f("basicCorrection/tone/whites"), + f("basicCorrection/tone/blacks"), + f("basicCorrection/saturation"), + f("creative/lookIntensity"), + f("creative/adjustments/fadedFilm"), + f("creative/adjustments/vibrance"), + f("creative/adjustments/creativeSaturation"), + (0.5 + 0.25 * f("creative/adjustments/tintBalance")).clamp(0.05, 0.95), + ]; + d.extend(tint_offset(pr.color("creative/adjustments/shadowTint"))); + d.extend(tint_offset(pr.color("creative/adjustments/highlightTint"))); + for c in ["Master", "Red", "Green", "Blue"] { + for part in ["Shadows", "Midtones", "Highlights"] { + d.push(f(&format!("curves/curve{c}{part}"))); + } + } + for w in ["shadowsWheel", "midtonesWheel", "highlightsWheel"] { + d.extend(tint_offset(pr.color(&format!("colorWheels/{w}")))); + } + let (lx, ly) = (b.offset[0], b.offset[1]); + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + d.extend([ + pr.f("vignette/vignetteAmount") as f32, + f("vignette/vignetteMidpoint") * 0.9, + f("vignette/vignetteRoundness"), + f("vignette/vignetteFeather"), + (lx + lw * 0.5) as f32, + (ly + lh * 0.5) as f32, + lw as f32, + lh as f32, + (lw / lh.max(1e-6)) as f32, + ]); + debug_assert_eq!(d.len(), 50); + let buf = e.data(&d); + let out = e.image(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 { + return Some(GBuf { img: out, ..b }); + } + let luma = e.image(out.width, out.height); + e.pixels("fxc_luma", &Params::default(), &out, None, &luma, None); + let blurred = gaussian_blur(e, &luma, b.scale, b.scale, true); + let mut p = Params::default(); + p.f[0][0] = sharpen * 2.0; + let sharp = e.image(out.width, out.height); + e.pixels("fxc_sharpen", &p, &out, Some(&blurred), &sharp, None); + Some(GBuf { img: sharp, ..b }) +} + +// ---------------------------------------------------------------- planes + +/// util::morph_plane with the same radius on both axes; `modes` = per-channel operation +/// (0 erode, 1 dilate, 2 keep). +fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage { + if r == 0 { + return img.clone(); + } + let mut cur = img.clone(); + for vertical in [0, 1] { + let mut p = Params::default(); + p.u[0] = [r, vertical, 0, 0]; + p.u[1] = modes; + let out = e.image(cur.width, cur.height); + e.pixels("fxc_morph", &p, &cur, None, &out, None); + cur = out; + } + cur +} + +/// util::morph_frac (blend between the two nearest integer radii). +fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage { + if radius <= 0.0 { + return img.clone(); + } + let r0 = radius.floor() as u32; + let t = (radius - r0 as f64) as f32; + let a = morph(e, img, r0, modes); + if t < 1e-4 { + return a; + } + 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); + e.pixels("fxc_lerp", &p, &a, Some(&b), &out, None); + out +} + +const ERODE_X: [u32; 4] = [0, 2, 2, 2]; +const DILATE_X: [u32; 4] = [1, 2, 2, 2]; + +// ---------------------------------------------------------------- Key Light + +/// keylight::primary. +fn primary(s: [f32; 3]) -> [u32; 3] { + let pi: usize = if s[1] >= s[0] && s[1] >= s[2] { + 1 + } else if s[2] >= s[0] { + 2 + } else { + 0 + }; + let (a, b) = match pi { + 0 => (1, 2), + 1 => (0, 2), + _ => (0, 1), + }; + let ix = if s[a] >= s[b] { [pi, a, b] } else { [pi, b, a] }; + ix.map(|i| i as u32) +} + +fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] { + let l = luminance(bias[0], bias[1], bias[2]).max(1e-4); + [0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4)) +} + +fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let view = pr.e("view"); + if view == 0 { + return Some(b); + } + // Inside / Outside masks (polygon coverage) run on the CPU. + let has_mask = |id: &str| pr.e(id).checked_sub(1).is_some_and(|i| ctx.env.masks.get(i as usize).is_some()); + if has_mask("insideMask/insideMask") || has_mask("outsideMask/outsideMask") { + return None; + } + let sc = pr.color("screenColour"); + let screen = [sc[0], sc[1], sc[2]]; + let ix = primary(screen); + let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize); + let gain = pr.f("screenGain") as f32 / 100.0; + let bal = pr.f("screenBalance") as f32 / 100.0; + let dbias = pr.color("despillBias"); + let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") }; + let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]); + let s_alpha = neutralise(screen, abias); + let sd = (s_alpha[pi] - (bal * s_alpha[o1] + (1.0 - bal) * s_alpha[o2])).max(1e-3); + let cb = pr.f("screenMatte/clipBlack") as f32 / 100.0; + let cw = pr.f("screenMatte/clipWhite") as f32 / 100.0; + let rollback = pr.f("screenMatte/clipRollback") * b.scale; + let preblur = pr.f("screenPreblur") * b.scale; + let sg = pr.f("screenMatte/screenShrinkGrow") * b.scale; + let soft = pr.f("screenMatte/screenSoftness") * b.scale; + let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale; + let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale; + // Hard Colour replaces wherever the screen matte exceeds the raw matte by any amount; after + // Screen Softness's blur that compares float-rounded sums, so it runs on the CPU. + if pr.e("screenMatte/replaceMethod") == 2 && soft > 0.0 { + return None; + } + let (w, h) = (b.img.width, b.img.height); + + let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() }; + let mut p = Params::default(); + p.u[0] = [ix[0], ix[1], ix[2], 0]; + p.f[0] = [abias[0], abias[1], abias[2], sd]; + p.f[1] = [gain, bal, cb, cw]; + let raw = e.image(w, h); + e.pixels("fxc_kl_raw", &p, &key_src, None, &raw, None); + 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); + e.pixels("fxc_kl_rollback", &Params::default(), &sg_img, Some(&raw), &out, None); + m = out; + } + if despot_b > 0.0 { + let d = morph_frac(e, &m, despot_b, DILATE_X); + m = morph_frac(e, &d, despot_b, ERODE_X); + } + if despot_w > 0.0 { + let d = morph_frac(e, &m, despot_w, ERODE_X); + m = morph_frac(e, &d, despot_w, DILATE_X); + } + if sg < 0.0 { + m = morph_frac(e, &m, -sg, ERODE_X); + } else if sg > 0.0 { + m = morph_frac(e, &m, sg, DILATE_X); + } + if soft > 0.0 { + m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true); + } + let mut planes = e.image(w, h); + e.pixels("fxc_kl_pack", &Params::default(), &m, Some(&raw), &planes, None); + let src_alpha_mode = pr.e("insideMask/sourceAlpha"); + if pr.b("edgeColourCorrection/enableEdgeColourCorrection") { + let grow = pr.f("edgeColourCorrection/edgeGrow") * b.scale; + let hard = (pr.f("edgeColourCorrection/edgeHardness") / 100.0) as f32; + let esoft = pr.f("edgeColourCorrection/edgeSoftness") * b.scale; + let mut p = Params::default(); + p.u[0][0] = src_alpha_mode; + let mut band = e.image(w, h); + e.pixels("fxc_kl_band", &p, &b.img, Some(&planes), &band, None); + if grow > 0.0 { + band = morph_frac(e, &band, grow, DILATE_X); + } + if esoft > 0.0 { + band = gaussian_blur(e, &band, esoft * 0.5, esoft * 0.5, true); + } + let mut p = Params::default(); + p.f[0][0] = hard; + let out = e.image(w, h); + e.pixels("fxc_kl_edge", &p, &planes, Some(&band), &out, None); + planes = out; + } + let pc = |k: &str| pr.f(k) as f32 / 100.0; + let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4); + let mut p = Params::default(); + p.u[0] = [ix[0], ix[1], ix[2], view]; + p.u[1] = [ + pr.e("screenMatte/replaceMethod"), + pr.b("foregroundColourCorrection/enableColourCorrection") as u32, + pr.b("unpremultiplyResult") as u32, + src_alpha_mode, + ]; + p.f[0] = [screen[0], screen[1], screen[2], bal]; + p.f[1] = [dbias[0], dbias[1], dbias[2], l0]; + p.f[2] = pr.color("screenMatte/replaceColour"); + p.f[3] = + [pc("foregroundColourCorrection/saturation"), pc("foregroundColourCorrection/contrast") + 1.0, pc("foregroundColourCorrection/brightness") + 1.0, 0.0]; + p.f[4] = [pc("edgeColourCorrection/edgeSaturation"), pc("edgeColourCorrection/edgeContrast") + 1.0, pc("edgeColourCorrection/edgeBrightness") + 1.0, 0.0]; + 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); + e.pixels("fxc_kl_final", &p, &b.img, Some(&planes), &out, None); + Some(GBuf { img: out, ..b }) +} diff --git a/crates/gpu/src/fx_distort.rs b/crates/gpu/src/fx_distort.rs new file mode 100644 index 00000000..bcd7214f --- /dev/null +++ b/crates/gpu/src/fx_distort.rs @@ -0,0 +1,413 @@ +//! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. +//! +//! Parameter conversions, padding and anything with a per-row or per-column structure (map +//! coordinates, tile bounds, Mesh Warp's triangle buckets) are computed here in f64 exactly as +//! the CPU does and uploaded; the kernels do the per-pixel work. + +use std::f64::consts::TAU; + +use effectcraft_effects::EffectCtx; +use effectcraft_effects::util::{Src, src_at}; + +use crate::context::{Enc, Params}; +use crate::effects::GBuf; + +/// Compute entry points in `fx_distort.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "dst_twirl", + "dst_bulge", + "dst_wavewarp", + "dst_ripple", + "dst_cclens", + "dst_turbulent", + "dst_displace", + "dst_mesh", + "dst_mosaic_rows", + "dst_mosaic_tiles", + "dst_mosaic_out", + "dst_mosaic_sharp", + "dst_findedges", + "dst_emboss", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.distort.turbulentdisplace", + "ec.distort.displacementmap", + "ec.distort.wavewarp", + "ec.distort.ripple", + "ec.distort.twirl", + "ec.distort.bulge", + "ec.distort.cclens", + "ec.distort.meshwarp", + "ec.stylize.mosaic", + "ec.stylize.findedges", + "ec.stylize.emboss", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.distort.turbulentdisplace" => turbulent(e, ctx, b), + "ec.distort.displacementmap" => displacement_map(e, ctx, b), + "ec.distort.wavewarp" => wave_warp(e, ctx, b), + "ec.distort.ripple" => ripple(e, ctx, b), + "ec.distort.twirl" => twirl(e, ctx, b), + "ec.distort.bulge" => bulge(e, ctx, b), + "ec.distort.cclens" => cc_lens(e, ctx, b), + "ec.distort.meshwarp" => mesh_warp(e, ctx, b), + "ec.stylize.mosaic" => mosaic(e, ctx, b), + "ec.stylize.findedges" => find_edges(e, ctx, b), + "ec.stylize.emboss" => emboss(e, ctx, b), + _ => None, + } +} + +/// 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 { + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, None, &out, data); + b.img = out; + Some(b) +} + +/// Buf::layer_w / layer_h (distort.rs). +fn layer_wh(ctx: &EffectCtx) -> (f64, f64) { + (ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0)) +} + +/// util::layer_rect. +fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> [f32; 4] { + [b.offset[0] as f32, b.offset[1] as f32, (ctx.layer_size[0] * b.scale) as f32, (ctx.layer_size[1] * b.scale) as f32] +} + +// ---------------------------------------------------------------- distort.rs + +fn twirl(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("angle").to_radians(); + let (lw, lh) = layer_wh(ctx); + let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32]; + run(e, "dst_twirl", &p, b, None) +} + +fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let rx = (ctx.params.f("hradius") * b.scale).max(1.0); + let ry = (ctx.params.f("vradius") * b.scale).max(1.0); + let c = b.to_px(ctx.params.v2("center")); + let h = ctx.params.f("height"); + let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0; + let pin = ctx.params.b("pinAllEdges"); + let (lw, lh) = layer_wh(ctx); + let (lw, lh) = (lw * b.scale, lh * b.scale); + let edge = (lw.min(lh) * 0.1).max(1.0); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, rx as f32, ry as f32]; + p.f[1] = [h as f32, taper as f32, if pin { 1.0 } else { 0.0 }, edge as f32]; + p.f[2] = [b.offset[0] as f32, b.offset[1] as f32, lw as f32, lh as f32]; + run(e, "dst_bulge", &p, b, None) +} + +fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let h = ctx.params.f("height") * b.scale; + let w = (ctx.params.f("width") * b.scale).max(1.0); + let dir = ctx.params.f("direction").to_radians(); + let speed = ctx.params.f("speed"); + let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU; + let (dx, dy) = (dir.sin(), -dir.cos()); + if !ctx.adjustment { + b.pad(e, h.abs().ceil() as u32 + 1)?; + } + let (lw, lh) = layer_wh(ctx); + let rect = [b.offset[0] as f32, b.offset[1] as f32, (lw * b.scale).max(1.0) as f32, (lh * b.scale).max(1.0) as f32]; + // The wave phase in cycles, (x·dx + y·dy) / width + phase / 2π, is separable: per column + // and per row (fraction, whole cycles) in f64, so the kernel's sum keeps the fraction exact + // to f32 (the circle shapes have vertical tangents where a phase error is amplified). + let (bw, bh) = (b.img.width as usize, b.img.height as usize); + let mut data = vec![0.0f32; 2 * (bw + bh)]; + let split = |v: f64| (v - v.floor(), v.floor()); + for x in 0..bw { + let (f, i) = split((x as f64 + 0.5) * dx / w); + data[x] = f as f32; + data[bw + x] = i as f32; + } + for y in 0..bh { + let (f, i) = split((y as f64 + 0.5) * dy / w + phase / TAU); + data[2 * bw + y] = f as f32; + data[2 * bw + bh + y] = i as f32; + } + let mut p = Params::default(); + p.u[0] = [ctx.params.e("waveType"), ctx.params.e("pinning"), ctx.seed ^ 0x3a7e, bw as u32]; + p.f[0] = [dx as f32, dy as f32, h as f32, 0.0]; + p.f[2] = rect; + let buf = e.data(&data); + run(e, "dst_wavewarp", &p, b, Some(&buf)) +} + +fn ripple(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let (lw, lh) = layer_wh(ctx); + let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * 0.5 * b.scale).max(1.0); + let asymmetric = ctx.params.e("conversion") == 0; + let c = b.to_px(ctx.params.v2("center")); + let w = (ctx.params.f("waveWidth") * b.scale).max(1.0); + let h = ctx.params.f("waveHeight") * b.scale; + let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * TAU; + let mut p = Params::default(); + p.u[0][0] = asymmetric as u32; + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, w as f32]; + p.f[1] = [h as f32, phase.rem_euclid(TAU) as f32, 0.0, 0.0]; + run(e, "dst_ripple", &p, b, None) +} + +// ---------------------------------------------------------------- distort2.rs + +fn cc_lens(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let c = b.to_px(ctx.params.v2("center")); + let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale); + let r = (ctx.params.f("size") / 100.0 * lw.max(lh) * 0.5).max(0.5); + let k = 2f64.powf(ctx.params.f("convergence") / 50.0); + let mut p = Params::default(); + p.f[0] = [c.0 as f32, c.1 as f32, r as f32, k as f32]; + run(e, "dst_cclens", &p, b, None) +} + +fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let amount = ctx.params.f("amount") * b.scale; + if amount.abs() < 1e-9 { + return Some(b); + } + let kind = ctx.params.e("displacement"); + let size = (ctx.params.f("size") * b.scale).max(1.0); + if ctx.params.b("resizeLayer") && !ctx.adjustment { + b.pad(e, amount.abs().ceil() as u32 + 1)?; + } + let off = b.to_px(ctx.params.v2("offset")); + let oct = ctx.params.f("complexity").clamp(1.0, 10.0); + let evo = ctx.params.f("evolution") / 360.0; + let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0)); + let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 }; + let rect = layer_rect(ctx, &b); + let edge = ((ctx.layer_size[0] * b.scale).min(ctx.layer_size[1] * b.scale) * 0.1).max(1.0); + let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9)); + let (z0, z1, t) = match cycle { + Some(c) => { + let z = evo.rem_euclid(c); + (z as f32, (z - c) as f32, (z / c) as f32) + } + None => (evo as f32, 0.0, 0.0), + }; + let mut p = Params::default(); + p.u[0] = [kind, ctx.params.e("pinning"), seed, oct.ceil().max(1.0) as u32]; + p.u[1][0] = cycle.is_some() as u32; + p.f[0] = [off.0 as f32, off.1 as f32, size as f32, amount as f32]; + p.f[1] = [z0, z1, t, (oct - oct.floor()) as f32]; + p.f[2] = rect; + p.f[3] = [edge as f32, falloff, 0.0, 0.0]; + run(e, "dst_turbulent", &p, b, None) +} + +fn displacement_map(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let hs = src_at(ctx.params.e("useForHorizontal")); + let vs = src_at(ctx.params.e("useForVertical")); + let mh = ctx.params.f("maxHorizontal") * b.scale; + let mv = ctx.params.f("maxVertical") * b.scale; + let wrap = ctx.params.b("wrapPixelsAround"); + if ctx.params.b("expandOutput") && !wrap && !ctx.adjustment { + b.pad(e, mh.abs().max(mv.abs()).ceil() as u32)?; + } + let layer = ctx.layer_param("displacementMapLayer", false); + let behavior = ctx.params.e("displacementMapBehavior"); + let (w, h) = (b.img.width as usize, b.img.height as usize); + let inv = 1.0 / b.scale.max(1e-9); + let ls = ctx.layer_size; + // Per column / row: the map layer's buffer coordinate (fit_layer / Tile Map) and whether + // the pixel lies inside the centred map (Center Map). + let mut data = vec![0.0f32; 2 * (w + h)]; + let mut mode = 0u32; + let mut center = false; + let map = match &layer { + Some(o) => { + if o.buf.img.is_empty() { + return None; + } + let os = o.size; + let axis = |i: usize, k: usize| -> (f64, bool) { + let q = (i as f64 + 0.5 - b.offset[k]) * inv; + let map_q = match behavior { + 2 => (q + (os[k] - ls[k]) * 0.5).rem_euclid(os[k].max(1.0)), + 1 if ls[0] > 0.0 && ls[1] > 0.0 => q * (os[k] / ls[k]), + 1 => q, + _ => q + (os[k] - ls[k]) * 0.5, + }; + let c = q + (os[k] - ls[k]) * 0.5; + (map_q * o.buf.scale + o.buf.offset[k], (0.0..os[k]).contains(&c)) + }; + for x in 0..w { + let (m, inside) = axis(x, 0); + data[x] = m as f32; + data[w + h + x] = inside as u32 as f32; + } + for y in 0..h { + let (m, inside) = axis(y, 1); + data[w + y] = m as f32; + data[2 * w + h + y] = inside as u32 as f32; + } + mode = if behavior == 2 { 2 } else { 1 }; + center = behavior == 0; + Some(e.g.upload_image(&o.buf.img)?) + } + None => None, + }; + let idx = |s: Src| effectcraft_effects::util::SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32; + let mut p = Params::default(); + p.u[0] = [idx(hs), idx(vs), mode, wrap as u32 | (center as u32) << 1]; + 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); + e.pixels("dst_displace", &p, &b.img, map.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +fn mesh_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let rows = ctx.params.f("rows").round().clamp(1.0, 31.0) as usize; + let cols = ctx.params.f("columns").round().clamp(1.0, 31.0) as usize; + let q = ctx.params.f("quality").round().clamp(1.0, 10.0) as usize; + let offs = effectcraft_effects::parse_mesh(ctx.params.s("mesh")); + if offs.len() != (rows + 1) * (cols + 1) || offs.iter().all(|o| o.0 == 0.0 && o.1 == 0.0) { + return Some(b); + } + let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]); + let vert = |i: usize, j: usize| { + let o = offs[j * (cols + 1) + i]; + b.to_px([i as f64 / cols as f64 * lw + o.0, j as f64 / rows as f64 * lh + o.1]) + }; + let (nx, ny) = (cols * q, rows * q); + let mut dest = Vec::with_capacity((nx + 1) * (ny + 1)); + let mut srcp = Vec::with_capacity((nx + 1) * (ny + 1)); + for gj in 0..=ny { + for gi in 0..=nx { + let (ci, cj) = ((gi / q).min(cols - 1), (gj / q).min(rows - 1)); + let (u, v) = ((gi - ci * q) as f64 / q as f64, (gj - cj * q) as f64 / q as f64); + let (p00, p10, p01, p11) = (vert(ci, cj), vert(ci + 1, cj), vert(ci, cj + 1), vert(ci + 1, cj + 1)); + let x = (p00.0 * (1.0 - u) + p10.0 * u) * (1.0 - v) + (p01.0 * (1.0 - u) + p11.0 * u) * v; + let y = (p00.1 * (1.0 - u) + p10.1 * u) * (1.0 - v) + (p01.1 * (1.0 - u) + p11.1 * u) * v; + dest.push((x, y)); + 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. + let idx = |i: usize, j: usize| j * (nx + 1) + i; + let hh = b.img.height as usize; + let mut tri_data: Vec = Vec::with_capacity(nx * ny * 24); + let mut buckets: Vec> = vec![Vec::new(); hh]; + let mut t = 0u32; + for j in 0..ny { + for i in 0..nx { + let (a, b2, c, d) = (idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1)); + for tri in [[a, b2, c], [a, c, d]] { + let [ia, ib, ic] = tri; + let (pa, pb, pc) = (dest[ia], dest[ib], dest[ic]); + let den: f64 = (pb.1 - pc.1) * (pa.0 - pc.0) + (pc.0 - pb.0) * (pa.1 - pc.1); + // Degenerate triangles are skipped per pixel on the CPU; leaving them out of + // the buckets is the same. + if den.abs() < 1e-12 { + continue; + } + let ys = [pa.1, pb.1, pc.1]; + let y0 = (ys[0].min(ys[1]).min(ys[2]) - 0.5).floor().max(0.0) as usize; + let y1 = (ys[0].max(ys[1]).max(ys[2]) + 0.5).ceil().max(0.0) as usize; + for bucket in buckets.iter_mut().take(y1.min(hh)).skip(y0) { + bucket.push(t); + } + let (sa, sb, sc) = (srcp[ia], srcp[ib], srcp[ic]); + tri_data.extend( + [(pb.1 - pc.1) / den, (pc.0 - pb.0) / den, (pc.1 - pa.1) / den, (pa.0 - pc.0) / den, pc.0, pc.1, sa.0, sa.1, sb.0, sb.1, sc.0, sc.1] + .map(|v| v as f32), + ); + t += 1; + } + } + } + let row_starts = tri_data.len(); + let list_start = row_starts + hh + 1; + let total: usize = buckets.iter().map(Vec::len).sum(); + if list_start + total >= 1 << 24 { + // Indices past 2^24 are not exact in the f32 data buffer. + return None; + } + let mut data = tri_data; + data.reserve(hh + 1 + total); + let mut n = 0usize; + for bucket in &buckets { + data.push(n as f32); + n += bucket.len(); + } + data.push(n as f32); + for bucket in &buckets { + data.extend(bucket.iter().map(|&t| t as f32)); + } + let mut p = Params::default(); + p.u[0] = [row_starts as u32, list_start as u32, 0, 0]; + let buf = e.data(&data); + run(e, "dst_mesh", &p, b, Some(&buf)) +} + +// ---------------------------------------------------------------- misc.rs (Stylize) + +fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let hb = ctx.params.f("horizontal").round().max(1.0) as usize; + let vb = ctx.params.f("vertical").round().max(1.0) as usize; + let (w, h) = (b.img.width as usize, b.img.height as usize); + if w == 0 || h == 0 { + return Some(b); + } + let cw = (w as f64 / hb as f64).max(1.0); + let ch = (h as f64 / vb as f64).max(1.0); + let mut p = Params::default(); + if ctx.params.b("sharpColors") { + let centre = |i: usize, c: f64| (((i as f64 / c).floor() + 0.5) * c) as i64 as f32; + let data: Vec = (0..w).map(|x| centre(x, cw)).chain((0..h).map(|y| centre(y, ch))).collect(); + p.u[0][0] = w as u32; + let buf = e.data(&data); + return run(e, "dst_mosaic_sharp", &p, b, Some(&buf)); + } + let (nx, ny) = ((w as f64 / cw).ceil() as usize, (h as f64 / ch).ceil() as usize); + let tx: Vec = (0..w).map(|x| ((x as f64 / cw) as usize).min(nx - 1)).collect(); + let ty: Vec = (0..h).map(|y| ((y as f64 / ch) as usize).min(ny - 1)).collect(); + // Tile bounds: tile i covers the columns with tx == i (tx is non-decreasing). + let bounds = |t: &[usize], n: usize| -> Vec { (0..=n).map(|i| t.partition_point(|&v| v < i) as f32).collect() }; + let mut data: Vec = tx.iter().chain(&ty).map(|&v| v as f32).collect(); + data.extend(bounds(&tx, nx)); + data.extend(bounds(&ty, ny)); + p.u[0] = [w as u32, h as u32, nx as u32, ny as u32]; + let buf = e.data(&data); + let rows = e.image(nx as u32, h as u32); + 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); + e.pixels("dst_mosaic_out", &p, &tiles, None, &out, Some(&buf)); + b.img = out; + Some(b) +} + +fn find_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0][0] = ctx.params.f("blend") as f32 / 100.0; + run(e, "dst_findedges", &p, b, None) +} + +fn emboss(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let ang = ctx.params.f("direction").to_radians(); + let relief = ctx.params.f("relief") * b.scale; + let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief); + let mut p = Params::default(); + p.f[0] = [dx as f32, dy as f32, ctx.params.f("contrast") as f32 / 100.0, ctx.params.f("blend") as f32 / 100.0]; + run(e, "dst_emboss", &p, b, None) +} diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs new file mode 100644 index 00000000..976bfc8e --- /dev/null +++ b/crates/gpu/src/fx_generate.rs @@ -0,0 +1,404 @@ +//! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact +//! steps with the pixel loops as compute kernels. +//! +//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard +//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as +//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does. + +use effectcraft_color::BlendMode; +use effectcraft_effects::{EffectCtx, GrainLook}; + +use crate::context::{Enc, Params}; +use crate::effects::{GBuf, gaussian_blur}; + +/// Compute entry points in `fx_generate.wgsl`. +pub(crate) const KERNELS: &[&str] = &[ + "gen_radial_blur", + "gen_ccradialfast", + "gen_lens_pre", + "gen_row_prefix", + "gen_lens_blur", + "gen_linear_wipe", + "gen_venetian", + "gen_radial_wipe", + "gen_gradient_wipe", + "gen_cell_pattern", + "gen_checker", + "gen_grid", + "gen_fourcolor", + "gen_noise", + "gen_grain_planes", + "gen_grain_apply", +]; + +/// Effect ids implemented here. +pub(crate) const IDS: &[&str] = &[ + "ec.blur.radial", + "ec.blur.cameralens", + "ec.blur.ccradialfast", + "ec.transition.venetian", + "ec.transition.linearwipe", + "ec.transition.radialwipe", + "ec.transition.gradientwipe", + "ec.generate.cellpattern", + "ec.generate.checkerboard", + "ec.generate.grid", + "ec.generate.fourcolor", + "ec.noise.noise", + "ec.noise.addgrain", +]; + +/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU. +pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { + match id { + "ec.blur.radial" => radial_blur(e, ctx, b), + "ec.blur.cameralens" => camera_lens(e, ctx, b), + "ec.blur.ccradialfast" => radial_fast(e, ctx, b), + "ec.transition.venetian" => venetian(e, ctx, b), + "ec.transition.linearwipe" => linear_wipe(e, ctx, b), + "ec.transition.radialwipe" => radial_wipe(e, ctx, b), + "ec.transition.gradientwipe" => gradient_wipe(e, ctx, b), + "ec.generate.cellpattern" => cell_pattern(e, ctx, b), + "ec.generate.checkerboard" => checkerboard(e, ctx, b), + "ec.generate.grid" => grid(e, ctx, b), + "ec.generate.fourcolor" => four_color(e, ctx, b), + "ec.noise.noise" => noise(e, ctx, b), + "ec.noise.addgrain" => add_grain(e, ctx, b), + _ => None, + } +} + +/// Run a per-pixel kernel over the buffer. +fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option { + let buf = data.map(|d| e.data(d)); + let out = e.image(b.img.width, b.img.height); + e.pixels(entry, p, &b.img, aux, &out, buf.as_ref()); + Some(GBuf { img: out, ..b }) +} + +/// `v` as an f32 (hi, lo) pair. +fn hl(v: f64) -> [f32; 2] { + let h = v as f32; + [h, (v - h as f64) as f32] +} + +/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace). +fn gen_mode(i: u32) -> u32 { + match effectcraft_effects::gen_mode(i) { + None => 255, + Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32, + } +} + +// ---------------------------------------------------------------- blurs + +fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount"); + let zoom = ctx.params.e("type") == 1; + let c = b.to_px(ctx.params.v2("center")); + let amount = if zoom { amt / 100.0 } else { amt }; + if amount.abs() < 1e-6 { + return Some(b); + } + let n = 32usize; + let mut data = Vec::with_capacity(2 * n); + for i in 0..n { + let t = i as f64 / (n - 1) as f64 - 0.5; + if zoom { + data.push((1.0 + t * amount) as f32); + } else { + let (s, c) = (t * amount).to_radians().sin_cos(); + data.extend([s as f32, c as f32]); + } + } + let mut p = Params::default(); + p.u[0] = [zoom as u32, n as u32, 0, 0]; + p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0]; + run(e, "gen_radial_blur", &p, b, None, Some(&data)) +} + +fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5; + if s <= 0.0 { + return Some(b); + } + let c = b.to_px(ctx.params.v2("center")); + let mut p = Params::default(); + p.u[0][0] = ctx.params.e("zoom"); + p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0]; + run(e, "gen_ccradialfast", &p, b, None, None) +} + +fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { + let r = ctx.params.f("blurRadius").max(0.0) * b.scale; + if r < 0.5 { + return Some(b); + } + let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r); + let repeat = ctx.params.b("repeatEdge") || ctx.adjustment; + if !repeat { + b.pad(e, reach.ceil() as u32 + 1)?; + } + let linear = ctx.params.b("useLinear"); + let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0; + let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0; + let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32; + let (w, h) = (b.img.width, b.img.height); + let mut src = b.img.clone(); + if linear || boost > 1.0 { + let mut p = Params::default(); + p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0]; + p.f[0] = [thr, boost, sat, 0.0]; + let out = e.image(w, h); + e.pixels("gen_lens_pre", &p, &src, None, &out, None); + src = out; + } + let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum(); + let norm = 1.0 / count as f64; + // Large irises sum each span from per-row prefix sums; small ones add the pixels directly. + let prefix = count > 256; + let pre = if prefix { + if !e.g.fits(w + 1, h) { + return None; + } + let pre = e.image(w + 1, h); + e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1)); + Some(pre) + } else { + None + }; + let data: Vec = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect(); + let mut p = Params::default(); + p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32]; + p.f[0][0] = norm as f32; + let buf = e.data(&data); + let out = e.image(w, h); + e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf)); + b.img = out; + Some(b) +} + +// ---------------------------------------------------------------- transitions + +fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("angle").to_radians(); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (w, h) = (b.img.width as f64, b.img.height as f64); + let (dx, dy) = (ang.sin(), -ang.cos()); + let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)]; + let proj: Vec = corners.iter().map(|(x, y)| x * dx + y * dy).collect(); + let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max)); + let edge = lo - feather + (hi - lo + 2.0 * feather) * done; + let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + data.extend(hl((x as f64 + 0.5) * dx - edge)); + } + for y in 0..b.img.height { + data.extend(hl((y as f64 + 0.5) * dy)); + } + let mut p = Params::default(); + p.u[0][0] = 2 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_linear_wipe", &p, b, None, Some(&data)) +} + +fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let ang = ctx.params.f("direction").to_radians(); + let width = (ctx.params.f("width") * b.scale).max(1.0); + let feather = (ctx.params.f("feather") * b.scale).max(0.001); + let (dx, dy) = (ang.cos(), ang.sin()); + let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize); + for x in 0..b.img.width { + let cx = ((x as f64 + 0.5) * dx).rem_euclid(width); + data.extend(hl(cx - done * width)); + data.extend(hl(cx - width - done * width)); + data.extend(hl(cx - width)); + } + for y in 0..b.img.height { + data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width))); + } + let mut p = Params::default(); + p.u[0][0] = 6 * b.img.width; + p.f[0][0] = feather as f32; + run(e, "gen_venetian", &p, b, None, Some(&data)) +} + +fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = ctx.params.f("completion") / 100.0; + let start = ctx.params.f("startAngle"); + let c = b.to_px(ctx.params.v2("center")); + let dir = ctx.params.e("wipe"); + let feather = ctx.params.f("feather").max(0.01); + let edge = if dir == 2 { done * 180.0 } else { done * 360.0 }; + // Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies). + let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos()); + let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize); + for x in 0..b.img.width { + let ux = x as f64 + 0.5 - c.0; + for (s, co) in rots { + data.extend(hl(ux * co)); + data.push((ux * s) as f32); + } + } + for y in 0..b.img.height { + let uy = -(y as f64 + 0.5 - c.1); + for (s, co) in rots { + data.extend(hl(uy * s)); + data.push((uy * co) as f32); + } + } + let mut p = Params::default(); + p.u[0] = [9 * b.img.width, dir.min(2), 0, 0]; + p.f[0] = [edge as f32, feather as f32, 0.0, 0.0]; + run(e, "gen_radial_wipe", &p, b, None, Some(&data)) +} + +fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32; + if done <= 0.0 { + return Some(b); + } + let soft = (ctx.params.f("softness") / 100.0) as f32; + // The gradient layer is fetched and placed on the CPU (as the CPU effect does). + let grad = match ctx.layer_param("gradientLayer", false) { + Some(o) => { + // place_layer reads only the buffer's size, offset and scale. + let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] }; + let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale }; + let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement")); + Some(e.g.upload_image(&img)?) + } + None => None, + }; + let mut p = Params::default(); + p.u[0][0] = ctx.params.b("invert") as u32; + p.f[0] = [done, soft, 0.0, 0.0]; + let out = e.image(b.img.width, b.img.height); + e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None); + Some(GBuf { img: out, ..b }) +} + +// ---------------------------------------------------------------- generate + +fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let pr = ctx.params; + let size = (pr.f("size") * b.scale).max(1.0); + let off = b.to_px(pr.v2("offset")); + let mut evo = pr.f("evolution") / 360.0; + if pr.b("evolutionOptions/cycleEvolution") { + evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0)); + } + let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11; + let tile = pr.b("tilingOptions/enableTiling"); + let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64; + let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64; + if nx > i32::MAX as i64 || ny > i32::MAX as i64 { + return None; + } + let mut p = Params::default(); + p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed]; + p.u[1] = [tile as u32, nx as u32, ny as u32, 0]; + // cos / sin of 2π·(h + evo) only see evo modulo 1. + p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32]; + p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0]; + run(e, "gen_cell_pattern", &p, b, None, None) +} + +/// generate::cell_size. +fn cell_size(ctx: &EffectCtx, b: &GBuf, anchor: (f64, f64)) -> (f64, f64) { + let (w, h) = match ctx.params.e("sizeFrom") { + 0 => { + let c = b.to_px(ctx.params.v2("corner")); + ((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs()) + } + 1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale), + _ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale), + }; + (w.max(1.0), h.max(1.0)) +} + +fn checkerboard(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0][0] = gen_mode(ctx.params.e("blendingMode")); + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_checker", &p, b, None, None) +} + +fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let anchor = b.to_px(ctx.params.v2("anchor")); + let (w, h) = cell_size(ctx, &b, anchor); + let border = (ctx.params.f("border") * b.scale).max(0.0); + let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0); + let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0); + let mut p = Params::default(); + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0]; + p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32]; + p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32]; + p.f[2] = ctx.params.color("color"); + run(e, "gen_grid", &p, b, None, None) +} + +fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let mut p = Params::default(); + for i in 0..4 { + let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1))); + p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0]; + p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1)); + } + let blend = ctx.params.f("blend").max(1.0); + let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32; + p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0]; + p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0]; + run(e, "gen_fourcolor", &p, b, None, None) +} + +// ---------------------------------------------------------------- noise + +fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let amt = ctx.params.f("amount") as f32 / 100.0; + let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool()); + let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed; + let mut p = Params::default(); + p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width]; + p.f[0][0] = amt; + run(e, "gen_noise", &p, b, None, None) +} + +fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option { + let look = GrainLook::from_params(ctx); + if look.intensity <= 0.0 { + return Some(b); + } + let (w, h) = (b.img.width, b.img.height); + // GrainLook::planes. + let size = look.size * b.scale as f32; + let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.seed, 101, 0]; + p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame]; + p.f[1] = [s(0), s(1), s(2), 0.0]; + let mut planes = e.image(w, h); + e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None); + let soft = look.softness * b.scale; + if soft > 0.05 { + planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true); + } + // GrainLook::color's tint factors. + let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3); + let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l); + let mut p = Params::default(); + p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0]; + p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0]; + p.f[1] = [tint[0], tint[1], tint[2], 0.0]; + p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0]; + p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0]; + run(e, "gen_grain_apply", &p, b, Some(&planes), None) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 43474e9d..bf47e3c5 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -6,8 +6,10 @@ //! layer buffers (once per buffer), applies the layer transforms with the CPU's sampling //! (nearest / bilinear / bicubic, minification pre-filter), motion-blur sub-samples, track //! mattes, Preserve Transparency, layer styles' passes, all 38 blend modes and the 8/16 bpc -//! clamping and quantisation, and converts colour spaces. 3D runs, adjustment layers and -//! wireframes run on the CPU between GPU steps (read back, draw, upload). +//! clamping and quantisation, and converts colour spaces. Classic 3D runs composite here too +//! (`classic3d`: per-pixel fragment sort, lights, ray-cast shadows), and adjustment layers run +//! their effect stacks on the GPU-resident comp. Advanced 3D compositing and wireframes run on +//! the CPU between GPU steps (read back, draw, upload). //! //! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR, //! image-based light, shadow maps), see [`Accelerator::raster_3d`]. @@ -16,15 +18,24 @@ //! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are //! uploaded and read back once. //! +//! GPU particles (`particles`): the stepped particle effects hand their simulation to +//! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle +//! and GPU-resident checkpoints. +//! //! Plug a [`Gpu`] into [`Renderer::accel`] (it implements [`Accelerator`]); renders then use it //! when [`RenderOpts::backend`](effectcraft_render::RenderOpts) asks for it. The viewer can //! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for //! egui-wgpu to draw. mod adv3d; +mod classic3d; mod context; mod effects; +mod fx_color; +mod fx_distort; +mod fx_generate; mod ops; +mod particles; mod walk; use std::sync::Arc; @@ -135,9 +146,31 @@ impl Accelerator for Gpu { fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option { adv3d::render(&self.ctx, scene) } + + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim) + } +} + +impl effectcraft_effects::psim::ParticleSim for Gpu { + fn simulate(&self, req: &effectcraft_effects::psim::SimRequest) -> Option> { + particles::simulate(&self.ctx, req) + } } #[cfg(test)] mod tests; #[cfg(test)] +mod tests_3d; +#[cfg(test)] +mod tests_adjust; +#[cfg(test)] mod tests_adv3d; +#[cfg(test)] +mod tests_fx_color; +#[cfg(test)] +mod tests_fx_distort; +#[cfg(test)] +mod tests_fx_generate; +#[cfg(test)] +mod tests_particles; diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index f8248f99..42bd9f37 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -134,6 +134,17 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo out } +/// Adjustment layer finish: move `canvas` toward `adjusted` by `matte`'s alpha × `opacity`. +pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &GpuImage, opacity: f32) -> GpuImage { + let (rows, stride) = e.image_rows(matte); + let mut p = Params::default(); + p.u[0][0] = stride; + p.f[0][0] = opacity; + let out = e.image(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 { let mut p = Params::default(); diff --git a/crates/gpu/src/particles.rs b/crates/gpu/src/particles.rs new file mode 100644 index 00000000..669a4ba0 --- /dev/null +++ b/crates/gpu/src/particles.rs @@ -0,0 +1,209 @@ +//! GPU particles (`particles.wgsl`): the stepped particle systems of `effectcraft-effects` +//! ([`effectcraft_effects::psim`]) simulated with one invocation per particle. States stay on +//! the GPU as checkpoints (per system key, like the CPU's `SimCache`), so playback advances from +//! the previous frame's state instead of simulating from layer time 0. + +use std::sync::{Mutex, OnceLock}; + +use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest}; +use wgpu::util::DeviceExt; + +use crate::context::GpuContext; + +/// Bytes of one particle state (`PState` in the shader). +const STATE_BYTES: u64 = 48; +/// Checkpoints kept (all keys). +const CHECKPOINTS: usize = 24; + +pub(crate) struct Pipes { + bgl: wgpu::BindGroupLayout, + pipe: wgpu::ComputePipeline, +} + +struct Checkpoint { + key: u64, + steps: u64, + n: u32, + buf: wgpu::Buffer, + used: u64, +} + +#[derive(Default)] +pub(crate) struct Checkpoints { + list: Vec, + clock: u64, +} + +fn pipes(g: &GpuContext) -> &Pipes { + g.particles.get_or_init(|| { + let d = &g.device; + let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/particles.wgsl")].concat(); + let module = + d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft particles"), source: wgpu::ShaderSource::Wgsl(src.into()) }); + let buffer = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let bgl = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effectcraft particles"), + entries: &[ + buffer(0, wgpu::BufferBindingType::Uniform), + buffer(1, wgpu::BufferBindingType::Storage { read_only: true }), + buffer(2, wgpu::BufferBindingType::Storage { read_only: true }), + buffer(3, wgpu::BufferBindingType::Storage { read_only: false }), + ], + }); + let layout = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effectcraft particles"), + bind_group_layouts: &[None, Some(&bgl)], + immediate_size: 0, + }); + let pipe = d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("particles"), + layout: Some(&layout), + module: &module, + entry_point: Some("particles"), + compilation_options: Default::default(), + cache: None, + }); + Pipes { bgl, pipe } + }) +} + +/// The shader's parameter block. +fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec { + let dt = (1.0 / effectcraft_effects::psim::SPS) as f32; + let mut u = [n, s0 as u32, req.steps as u32, 0]; + let u2; + let mut f = [[0.0f32; 4]; 6]; + match &req.system { + ParticleSystem::Engine(e) => { + u2 = [e.anim, e.flat as u32, e.seed, prev_n]; + f[0] = [e.life, e.speed, e.extra, e.cone]; + f[1] = [e.producer[0], e.producer[1], e.producer[2], 0.0]; + f[2] = [e.radius[0], e.radius[1], e.radius[2], 0.0]; + f[3] = [e.gravity[0], e.gravity[1], e.gravity[2], 0.0]; + f[4] = [e.axis[0], e.axis[1], e.axis[2], 0.0]; + f[5] = [e.damp, dt, 0.0, 0.0]; + } + ParticleSystem::Cannon(c) => { + u[3] = 1; + u2 = [0, 1, c.seed, prev_n]; + f[0] = [c.pos[0], c.pos[1], c.barrel_angle, c.barrel_radius]; + f[1] = [c.dir_spread, c.vel, c.vel_spread, c.grav_spread]; + f[2] = [c.gravity[0], c.gravity[1], dt, 0.0]; + f[3] = c.bounds; + } + } + let mut out = Vec::with_capacity(128); + for x in u.iter().chain(&u2) { + out.extend_from_slice(&x.to_le_bytes()); + } + for x in f.iter().flatten() { + out.extend_from_slice(&x.to_le_bytes()); + } + out +} + +/// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]). +pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option> { + if !g.can_readback() || req.steps > u32::MAX as u64 { + return None; + } + let n = req.births.len() as u32; + if n == 0 { + return Some(vec![]); + } + let size = n as u64 * STATE_BYTES; + if size > g.device.limits().max_storage_buffer_binding_size { + return None; + } + let p = pipes(g); + // The latest checkpoint at or before the target step. + let (s0, prev, prev_n) = { + let mut c = g.particle_states.lock().ok()?; + c.clock += 1; + let now = c.clock; + match c.list.iter_mut().filter(|k| k.key == req.key && k.steps <= req.steps).max_by_key(|k| k.steps) { + Some(k) => { + k.used = now; + (k.steps, Some(k.buf.clone()), k.n) + } + None => (0, None, 0), + } + }; + let d = &g.device; + let births: Vec = req.births.iter().flat_map(|b| b.to_le_bytes()).collect(); + let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE }); + let prev = prev.unwrap_or_else(|| { + d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false }) + }); + let next = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("particles"), + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let ub = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("particles"), + contents: &uniforms(req, n, s0, prev_n), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bg = d.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &p.bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: births.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: prev.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: next.as_entire_binding() }, + ], + }); + let read = d.create_buffer(&wgpu::BufferDescriptor { + label: Some("particles readback"), + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("particles") }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("particles"), timestamp_writes: None }); + pass.set_pipeline(&p.pipe); + pass.set_bind_group(1, &bg, &[]); + pass.dispatch_workgroups(n.div_ceil(64), 1, 1); + } + enc.copy_buffer_to_buffer(&next, 0, &read, 0, size); + g.queue.submit([enc.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + read.map_async(wgpu::MapMode::Read, .., move |r| { + let _ = tx.send(r); + }); + g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?; + rx.recv().ok()?.ok()?; + let view = read.get_mapped_range(..).ok()?; + let f: Vec = view.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect(); + drop(view); + read.unmap(); + let parts = f + .chunks_exact(12) + .enumerate() + .filter(|(_, s)| s[9] != 0.0) + .map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 }) + .collect(); + // Keep the new state as a checkpoint. + if let Ok(mut c) = g.particle_states.lock() { + let now = c.clock; + c.list.retain(|k| !(k.key == req.key && k.steps == req.steps)); + c.list.push(Checkpoint { key: req.key, steps: req.steps, n, buf: next, used: now }); + while c.list.len() > CHECKPOINTS { + let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break }; + c.list.remove(i); + } + } + Some(parts) +} + +pub(crate) type PipesCell = OnceLock; +pub(crate) type StatesCell = Mutex; diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl new file mode 100644 index 00000000..4e8d2139 --- /dev/null +++ b/crates/gpu/src/shaders/classic3d.wgsl @@ -0,0 +1,460 @@ +// Classic 3D on the GPU: the per-pixel compositor of `render::three_d::compose` (draw_run). +// +// Every plane covering an output pixel is inverse-mapped through its homography (one per +// motion-blur sub-sample), sampled from the plane atlas (`src`), shaded by the comp's lights +// (Blinn-Phong, ray-cast shadows against the caster planes) and multiplied by its track matte; +// the pixel's fragments are insertion-sorted far → near (coplanar fragments in stack order) and +// blended over the canvas (`aux`) with each layer's mode and opacity. +// +// Bindings: group 0 as every kernel (P, src = atlas, aux = canvas, out, data = track mattes); +// group 1 = planes, geometries, lights, caster indices. +// P.u[0] = (planes, lights, casters, _) + +struct Plane { + // Atlas rectangle (x, y, w, h) in texels. + atlas: vec4, + // Buffer offset (x, y), scale, bicubic (0 / 1). + tex: vec4, + // Output pixel bounds [x0, y0, x1, y1). + bbox: vec4, + // Layer-space bounds of the buffer. + bounds: vec4, + // World → layer (affine rows). + winv0: vec4, + winv1: vec4, + winv2: vec4, + // Ambient, diffuse, specular, Blinn-Phong exponent. + mat0: vec4, + // Metal, light transmission, opacity. + mat1: vec4, + // First geometry, geometry count, flags (1 draw, 2 preserve transparency, 4 accepts + // lights), blend mode. + ints: vec4, + // Casts shadows, accepts shadows (0 off, 1 on, 2 only), layer id, stack order. + ints2: vec4, + // Track matte (index + 1; 0 = none), dissolve seed. + ints3: vec4, +} + +struct Geo { + // Output pixel → homogeneous layer coordinates (rows). + h0: vec4, + h1: vec4, + h2: vec4, + // Camera depth = d.x·u + d.y·v + d.z. + depth: vec4, + // Layer → world (affine rows). + w0: vec4, + w1: vec4, + w2: vec4, + // Unit world normal. + normal: vec4, + // Camera eye (xyz) and orthographic flag (w). + eye: vec4, + // Camera forward (xyz). + fwd: vec4, +} + +struct Light { + // Kind (0 ambient, 1 parallel, 2 point, 3 spot), falloff, casts shadows. + ints: vec4, + pos: vec4, + dir: vec4, + color: vec4, + // Cone half-angle, feather, radius, falloff distance. + cone: vec4, + // Shadow darkness, shadow diffusion. + shadow: vec4, +} + +@group(1) @binding(0) var planes: array; +@group(1) @binding(1) var geos: array; +@group(1) @binding(2) var lights: array; +@group(1) @binding(3) var casters: array; + +const NEAR3: f32 = 1.0; +const MAX_FRAGS: u32 = 32u; + +fn atlas_get(i: u32, x: i32, y: i32) -> vec4 { + let r = planes[i].atlas; + if (x < 0 || y < 0 || x >= i32(r.z) || y >= i32(r.w)) { + return vec4(0.0); + } + return textureLoad(src, vec2(i32(r.x) + x, i32(r.y) + y), 0); +} + +// Image::sample_bilinear / sample_bicubic on a plane's buffer at layer coordinates (u, v). +fn texel(i: u32, u: f32, v: f32) -> vec4 { + let t = planes[i].tex; + let x = u * t.z + t.x; + let y = v * t.z + t.y; + let fx = x - 0.5; + let fy = y - 0.5; + let x0 = floor(fx); + let y0 = floor(fy); + let xi = i32(x0); + let yi = i32(y0); + if (t.w > 0.5) { + let wx = cubic_w(fx - x0); + let wy = cubic_w(fy - y0); + var o = vec4(0.0); + for (var j = 0; j < 4; j++) { + for (var k = 0; k < 4; k++) { + o += atlas_get(i, xi - 1 + k, yi - 1 + j) * (wx[k] * wy[j]); + } + } + return max(o, vec4(0.0)); + } + let tx = fx - x0; + let ty = fy - y0; + let a = atlas_get(i, xi, yi); + let b = atlas_get(i, xi + 1, yi); + let c = atlas_get(i, xi, yi + 1); + let d = atlas_get(i, xi + 1, yi + 1); + let top = a + (b - a) * tx; + let bot = c + (d - c) * tx; + return top + (bot - top) * ty; +} + +fn affine(r0: vec4, r1: vec4, r2: vec4, p: vec3) -> vec3 { + return vec3(dot(r0.xyz, p) + r0.w, dot(r1.xyz, p) + r1.w, dot(r2.xyz, p) + r2.w); +} + +fn linear3(r0: vec4, r1: vec4, r2: vec4, v: vec3) -> vec3 { + return vec3(dot(r0.xyz, v), dot(r1.xyz, v), dot(r2.xyz, v)); +} + +// compose::unproject: (u, v, depth, ok). +fn unproject(g: u32, sx: f32, sy: f32) -> vec4 { + let gm = geos[g]; + let ortho = gm.eye.w > 0.5; + let qz = gm.h2.x * sx + gm.h2.y * sy + gm.h2.z; + if ((!ortho && qz <= 0.0) || qz == 0.0) { + return vec4(0.0); + } + let u = (gm.h0.x * sx + gm.h0.y * sy + gm.h0.z) / qz; + let v = (gm.h1.x * sx + gm.h1.y * sy + gm.h1.z) / qz; + let z = gm.depth.x * u + gm.depth.y * v + gm.depth.z; + if (!ortho && z < NEAR3) { + return vec4(0.0); + } + return vec4(u, v, z, 1.0); +} + +// LightState::to_light: unit vector towards the light (xyz) and the distance (w; -1 = ∞). +fn to_light(l: u32, p: vec3) -> vec4 { + let li = lights[l]; + if (li.ints.x == 1u) { + return vec4(-li.dir.xyz, -1.0); + } + let d = li.pos.xyz - p; + let len = length(d); + if (len < 1e-9) { + return vec4(0.0, 0.0, -1.0, 0.0); + } + return vec4(d / len, len); +} + +fn smoothstep01(t: f32) -> f32 { + return 1.0 - t * t * (3.0 - 2.0 * t); +} + +// LightState::attenuation. +fn attenuation(l: u32, p: vec3) -> f32 { + let li = lights[l]; + let tl = to_light(l, p); + let dist = tl.w; + let finite = dist >= 0.0; + let radius = li.cone.z; + let fd = li.cone.w; + var k = 1.0; + if (li.ints.y == 1u && finite) { + if (dist <= radius) { + k = 1.0; + } else if (fd <= 0.0) { + k = 0.0; + } else { + k = smoothstep01(clamp((dist - radius) / fd, 0.0, 1.0)); + } + } else if (li.ints.y == 2u && finite) { + if (dist <= radius || (radius <= 0.0 && dist <= 1.0)) { + k = 1.0; + } else { + let q = max(radius, 1.0) / dist; + k = q * q; + } + } + if (li.ints.x == 3u) { + let cos_a = clamp(dot(-tl.xyz, li.dir.xyz), -1.0, 1.0); + let a = acos(cos_a); + let outer = li.cone.x; + let inner = outer * (1.0 - li.cone.y); + if (a <= inner) { + } else if (a >= outer) { + k = 0.0; + } else { + k *= smoothstep01((a - inner) / max(outer - inner, 1e-9)); + } + } + return k; +} + +// compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`. +fn occlusion(c: u32, p: vec3, l: u32) -> vec3 { + let pl = planes[c]; + let li = lights[l]; + let a = affine(pl.winv0, pl.winv1, pl.winv2, p); + var hit: vec3; + var d_pc: f32; + var d_cl: f32; + if (li.ints.x == 1u) { + // Towards the light, 1e6 away (in direction form: exact in f32). + let dl = linear3(pl.winv0, pl.winv1, pl.winv2, -li.dir.xyz); + if (dl.z == 0.0) { + return vec3(1.0); + } + let s = -a.z / dl.z; + if (s <= 1.0 || s >= 1.0e6) { + return vec3(1.0); + } + hit = a + dl * s; + d_pc = length(li.dir.xyz) * s; + d_cl = 1000.0; + } else { + let b = affine(pl.winv0, pl.winv1, pl.winv2, li.pos.xyz); + if (a.z == b.z || (a.z < 0.0) == (b.z < 0.0)) { + return vec3(1.0); + } + let t = a.z / (a.z - b.z); + if (t <= 1e-6 || t >= 1.0) { + return vec3(1.0); + } + hit = a + (b - a) * t; + let seg = length(li.pos.xyz - p); + d_pc = seg * t; + d_cl = max(seg * (1.0 - t), 1.0); + } + let r = li.shadow.y * min(d_pc / d_cl, 4.0); + let bd = pl.bounds; + if (hit.x < bd.x - r || hit.y < bd.y - r || hit.x > bd.z + r || hit.y > bd.w + r) { + return vec3(1.0); + } + var px = vec4(0.0); + if (r * pl.tex.z < 0.5) { + px = texel(c, hit.x, hit.y); + } else { + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0; + } + } + } + let alpha = clamp(px.w * pl.mat1.z, 0.0, 1.0); + if (alpha <= 0.0) { + return vec3(1.0); + } + let tr = pl.mat1.y; + let dark = li.shadow.x; + var o = vec3(1.0); + for (var k = 0; k < 3; k++) { + var st = 0.0; + if (px.w > 0.0) { + st = clamp(px[k] / px.w, 0.0, 1.0); + } + let through = (1.0 - alpha) + alpha * tr * st; + o[k] = 1.0 - dark * (1.0 - through); + } + return o; +} + +fn shadow_of(it: u32, l: u32, p: vec3) -> vec3 { + var tr = vec3(1.0); + let n = P.u[0].z; + let lid = planes[it].ints2.z; + for (var k = 0u; k < n; k++) { + let c = casters[k]; + if (c == it || planes[c].ints2.z == lid) { + continue; + } + tr *= occlusion(c, p, l); + } + return tr; +} + +// compose::shade_texel (light::shade). +fn shade_texel(it: u32, g: u32, u: f32, v: f32, t: vec4) -> vec4 { + let pl = planes[it]; + let n_lights = P.u[0].y; + let accepts_shadows = pl.ints2.y; + if (n_lights == 0u || (pl.ints.z & 4u) == 0u) { + if (accepts_shadows == 2u) { + return vec4(0.0); + } + return t; + } + let gm = geos[g]; + let a = t.w; + let rgb = t.xyz / a; + let p = affine(gm.w0, gm.w1, gm.w2, vec3(u, v, 0.0)); + var to_viewer: vec3; + if (gm.eye.w > 0.5) { + to_viewer = -gm.fwd.xyz; + } else { + to_viewer = normalize(gm.eye.xyz - p); + } + var n = gm.normal.xyz; + if (dot(n, to_viewer) < 0.0) { + n = -n; + } + var amb = vec3(0.0); + var diff = vec3(0.0); + var spec = vec3(0.0); + var shadow_amt = 0.0; + let ex = pl.mat0.w; + let metal = pl.mat1.x; + let transmission = pl.mat1.y; + let specular = pl.mat0.z; + for (var i = 0u; i < n_lights; i++) { + let li = lights[i]; + if (li.ints.x == 0u) { + amb += li.color.xyz; + continue; + } + let k = attenuation(i, p); + if (k <= 0.0) { + continue; + } + let lv = to_light(i, p).xyz; + let ndl = dot(n, lv); + let front = ndl > 0.0; + var lambert = ndl; + if (!front) { + lambert = -ndl * transmission; + } + if (lambert <= 0.0) { + continue; + } + var sh = vec3(1.0); + if (li.ints.z != 0u && accepts_shadows != 0u) { + sh = shadow_of(it, i, p); + } + shadow_amt = max(shadow_amt, 1.0 - (sh.x + sh.y + sh.z) / 3.0); + diff += li.color.xyz * (lambert * k) * sh; + if (front && specular > 0.0) { + let h = normalize(lv + to_viewer); + let s = powz(max(dot(n, h), 0.0), ex) * k; + let tint = vec3(1.0) + (rgb - vec3(1.0)) * metal; + spec += li.color.xyz * s * sh * tint; + } + } + let lit = rgb * (pl.mat0.x * amb + pl.mat0.y * diff) + specular * spec; + if (accepts_shadows == 2u) { + return vec4(0.0, 0.0, 0.0, a * shadow_amt); + } + return vec4(lit * a, a); +} + +// compose::before: a draws before b (farther; coplanar → lower stack order first). +fn before(za: f32, oa: u32, zb: f32, ob: u32) -> bool { + let tol = 1e-5 * max(max(abs(za), abs(zb)), 1.0); + if (abs(za - zb) <= tol) { + return oa < ob; + } + return za > zb; +} + +@compute @workgroup_size(16, 16) +fn classic3d(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let x = i32(gid.x); + let y = i32(gid.y); + if (x >= dims.x || y >= dims.y) { + return; + } + let pix = vec2(x, y); + var d = textureLoad(aux, pix, 0); + let fx = f32(x); + let fy = f32(y); + let sx = fx + 0.5; + let sy = fy + 0.5; + var fz: array; + var fo: array; + var fi: array; + var fp: array, MAX_FRAGS>; + var nf = 0u; + let n_planes = P.u[0].x; + for (var i = 0u; i < n_planes; i++) { + let pl = planes[i]; + if ((pl.ints.z & 1u) == 0u) { + continue; + } + if (fx < pl.bbox.x || fx >= pl.bbox.z || fy < pl.bbox.y || fy >= pl.bbox.w) { + continue; + } + // compose::fragment: average of the motion-blur sub-samples. + let g0 = pl.ints.x; + let ng = pl.ints.y; + var acc = vec4(0.0); + var depth = 0.0; + var hit = false; + for (var k = 0u; k < ng; k++) { + let q = unproject(g0 + k, sx, sy); + if (q.w == 0.0) { + continue; + } + let t = texel(i, q.x, q.y); + if (t.w <= 1e-6) { + continue; + } + if (!hit) { + depth = q.z; + hit = true; + } + acc += shade_texel(i, g0 + k, q.x, q.y, t); + } + if (!hit) { + continue; + } + if (ng > 1u) { + acc *= 1.0 / f32(ng); + } + let m = pl.ints3.x; + if (m != 0u) { + let f = data[(m - 1u) * u32(dims.x) * u32(dims.y) + u32(y) * u32(dims.x) + u32(x)]; + acc *= f; + } + if (!(acc.w > 0.0 || acc.x != 0.0 || acc.y != 0.0 || acc.z != 0.0) || nf >= MAX_FRAGS) { + continue; + } + // Insertion: far → near, coplanar in stack order (stable, as the CPU's insertion sort). + let order = pl.ints2.w; + var j = nf; + while (j > 0u && before(depth, order, fz[j - 1u], fo[j - 1u])) { + fz[j] = fz[j - 1u]; + fo[j] = fo[j - 1u]; + fi[j] = fi[j - 1u]; + fp[j] = fp[j - 1u]; + j--; + } + fz[j] = depth; + fo[j] = order; + fi[j] = i; + fp[j] = acc; + nf++; + } + for (var k = 0u; k < nf; k++) { + let pl = planes[fi[k]]; + var op = pl.mat1.z; + if ((pl.ints.z & 2u) != 0u) { + op *= d.w; + } + let s = fp[k] * op; + let mode = pl.ints.w; + var n = 0.5; + if (mode == 1u || mode == 2u) { + n = hash_noise(u32(x), u32(y), pl.ints3.y); + } + d = blend_pixel(mode, d, s, n); + } + textureStore(out, pix, d); +} diff --git a/crates/gpu/src/shaders/fx_color.wgsl b/crates/gpu/src/shaders/fx_color.wgsl new file mode 100644 index 00000000..97771a46 --- /dev/null +++ b/crates/gpu/src/shaders/fx_color.wgsl @@ -0,0 +1,707 @@ +// GPU effects (color family): see src/fx_color.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. +// Every name here is prefixed `fxc_` (the family files share one module). + +// ---------------------------------------------------------------- helpers + +// util::unpremul: straight colour (0 when alpha ≤ 1e-6). +fn fxc_unpremul(px: vec4) -> vec3 { + if (px.w > 1e-6) { + return px.xyz / px.w; + } + return vec3(0.0); +} + +// util::smoothstep. +fn fxc_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); +} + +fn fxc_mix(a: vec3, b: vec3, t: f32) -> vec3 { + return a + (b - a) * t; +} + +// color::hsv_to_rgb. +fn fxc_hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3 { + let h6 = fract_euclid(h) * 6.0; + let i = floor(h6); + let f = h6 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - s * f); + let t = v * (1.0 - s * (1.0 - f)); + switch i32(i) { + case 0: { return vec3(v, t, p); } + case 1: { return vec3(q, v, p); } + case 2: { return vec3(p, v, t); } + case 3: { return vec3(p, q, v); } + case 4: { return vec3(t, p, v); } + default: { return vec3(v, p, q); } + } +} + +fn fxc_pixel(gid: vec3) -> vec2 { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return vec2(-1); + } + return p; +} + +// ---------------------------------------------------------------- per-pixel colour effects + +// Colorama's input phase (color_fx::colorama_phase). +fn fxc_colorama_phase(c: vec3, a: f32, mode: u32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c.x; } + case 2u: { v = c.y; } + case 3u: { v = c.z; } + case 4u: { v = rgb_to_hsl(c).x; } + case 5u: { v = rgb_to_hsl(c).z; } + case 6u: { v = rgb_to_hsl(c).y; } + case 7u: { v = max(max(c.x, c.y), c.z); } + case 8u: { v = a; } + case 9u: { v = 0.0; } + default: { v = luminance(c); } + } + return clamp(v, 0.0, 1.0); +} + +// Selective Color range weights (color2::sc_weights). +fn fxc_sc_weights(c0: vec3) -> array { + let c = clamp(c0, vec3(0.0), vec3(1.0)); + // Stable descending sort of the channel indices. + var idx = vec3(0u, 1u, 2u); + for (var i = 1u; i < 3u; i++) { + var j = i; + while (j > 0u && c[idx[j]] > c[idx[j - 1u]]) { + let t = idx[j]; + idx[j] = idx[j - 1u]; + idx[j - 1u] = t; + j--; + } + } + let mx = c[idx.x]; + let md = c[idx.y]; + let mn = c[idx.z]; + var w = array(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + var prim = array(0u, 2u, 4u); + var sec = array(3u, 5u, 1u); + w[prim[idx.x]] = mx - md; + w[sec[idx.z]] = md - mn; + w[6] = max((mn - 0.5) * 2.0, 0.0); + w[8] = max((0.5 - mx) * 2.0, 0.0); + w[7] = clamp(1.0 - (abs(mx - 0.5) + abs(mn - 0.5)), 0.0, 1.0); + return w; +} + +// u[0].x = op: +// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity +// 2 Vibrance f[0] = (vibrance, saturation) +// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas); +// u[0].y = tint; f[2] = (tint hue, tint saturation) +// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend +// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend) +// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome +// 7 Selective Color data = 9 × (cyan, magenta, yellow, black); u[0].y = relative +// 8 Linear Color Key f[0] = (key rgb, tolerance), f[1] = (softness, key chroma xy, key hue); +// u[0] = (_, match mode, keep colours, view) +@compute @workgroup_size(16, 16) +fn fxc_point(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + switch P.u[0].x { + case 1u: { + if (a > 0.0) { + let c = px.xyz / a; + let l = clamp(luminance(c), 0.0, 1.0); + let ws = clamp(1.0 - l * 2.0, 0.0, 1.0); + let wh = clamp(l * 2.0 - 1.0, 0.0, 1.0); + let wm = 1.0 - ws - wh; + var o = max(c + (P.f[0].xyz * ws + P.f[1].xyz * wm + P.f[2].xyz * wh) * 0.5, vec3(0.0)); + if (P.u[0].y != 0u) { + let d = luminance(c) - luminance(o); + o = max(o + d, vec3(0.0)); + } + px = vec4(o * a, a); + } + } + case 2u: { + if (a > 0.0) { + let c = px.xyz / a; + let hsl = rgb_to_hsl(c); + let vib = P.f[0].x; + let sat = P.f[0].y; + let s = clamp(hsl.y * (1.0 + sat) + vib * (1.0 - hsl.y) * min(hsl.y, 0.5), 0.0, 1.0); + px = vec4(hsl_to_rgb(hsl.x, s, hsl.z) * a, a); + } + } + case 3u: { + if (a > 0.0) { + let c = px.xyz / a; + var w = array(P.f[0].x, P.f[0].y, P.f[0].z, P.f[0].w, P.f[1].x, P.f[1].y); + let hsl = rgb_to_hsl(c); + let hp = hsl.x * 6.0; + let i = u32(max(floor(hp), 0.0)) % 6u; + let f = hp - floor(hp); + let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f; + let base = luminance(c); + let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0); + var o = vec3(gray); + if (P.u[0].y != 0u) { + o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0)); + } + px = vec4(o * a, a); + } + } + case 4u: { + if (a > 0.0) { + let c = px.xyz / a; + let l = clamp(luminance(c), 0.0, 1.0); + var t: vec3; + if (l < 0.5) { + t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0); + } else { + t = fxc_mix(P.f[1].xyz, P.f[0].xyz, (l - 0.5) * 2.0); + } + px = vec4(fxc_mix(c, t, P.f[3].x) * a, a); + } + } + case 5u: { + if (a > 0.0) { + let c = px.xyz / a; + var ph = fract_euclid(fxc_colorama_phase(c, a, P.u[0].y) * P.f[0].x + P.f[0].y); + if (P.u[0].z == 0u) { + ph = floor(ph * 6.0) / 6.0; + } + let o = fxc_mix(c, fxc_hsv_to_rgb(ph, 1.0, 1.0), P.f[0].z); + px = vec4(o * a, a); + } + } + case 6u: { + if (a > 0.0) { + let c = px.xyz / a; + var o: vec3; + for (var i = 0; i < 3; i++) { + let r = P.f[i]; + o[i] = max(c.x * r.x + c.y * r.y + c.z * r.z + r.w, 0.0); + } + if (P.u[0].y != 0u) { + o = vec3(o.x); + } + px = vec4(o * a, a); + } + } + case 7u: { + // color2::map_ca: fully transparent black stays. + if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) { + textureStore(out, p, px); + return; + } + let c = fxc_unpremul(px); + var w = fxc_sc_weights(c); + var o = c; + let relative = P.u[0].y != 0u; + for (var r = 0u; r < 9u; r++) { + let wr = w[r]; + if (wr <= 0.0) { + continue; + } + let k = data[r * 4u + 3u]; + for (var i = 0u; i < 3u; i++) { + let ink = 1.0 - c[i]; + var delta = data[r * 4u + i] + k; + if (relative) { + delta = delta * max(ink, 0.0); + } + o[i] -= wr * delta; + } + } + let na = clamp(max(a, 0.0), 0.0, 1.0); + px = vec4(max(o, vec3(0.0)) * na, na); + } + case 8u: { + let c = fxc_unpremul(px); + let key = P.f[0].xyz; + let tol = P.f[0].w; + let soft = P.f[1].x; + var d: f32; + switch P.u[0].y { + case 1u: { + let hsl = rgb_to_hsl(c); + if (hsl.y < 0.02) { + d = 1.0; + } else { + let dh = abs(hsl.x - P.f[1].w); + d = min(dh, 1.0 - dh) * 2.0; + } + } + case 2u: { + let s = c.x + c.y + c.z; + var pc = vec2(1.0 / 3.0, 1.0 / 3.0); + if (s > 1e-6) { + pc = vec2(c.x / s, c.y / s); + } + let dx = pc.x - P.f[1].y; + let dy = pc.y - P.f[1].z; + d = sqrt(dx * dx + dy * dy) / 1.4142135; + } + default: { + let e = c - key; + d = sqrt(e.x * e.x + e.y * e.y + e.z * e.z) / 1.7320508; + } + } + var v: f32; + if (soft > 1e-6) { + v = clamp((d - tol) / soft, 0.0, 1.0); + } else if (d > tol) { + v = 1.0; + } else { + v = 0.0; + } + if (P.u[0].z != 0u) { + v = 1.0 - v; + } + let k = clamp(v, 0.0, 1.0); + if (P.u[0].w == 2u) { + let m = px.w * k; + px = vec4(m, m, m, 1.0); + } else { + px = px * k; + } + } + default: {} + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- Lumetri Color (color3) + +fn fxc_tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 { + if (s == 0.0 && m == 0.0 && h == 0.0) { + return x; + } + let t = clamp(x, 0.0, 1.0); + let d0 = (t - 0.25) / 0.25; + let d1 = (t - 0.5) / 0.25; + let d2 = (t - 0.75) / 0.25; + let b0 = max(1.0 - d0 * d0, 0.0); + let b1 = max(1.0 - d1 * d1, 0.0); + let b2 = max(1.0 - d2 * d2, 0.0); + return x + 0.25 * (s * (b0 * b0) + m * (b1 * b1) + h * (b2 * b2)); +} + +fn fxc_d3(i: u32) -> vec3 { + return vec3(data[i], data[i + 1u], data[i + 2u]); +} + +// data: 0 temperature, 1 tint, 2 exposure gain, 3 contrast, 4 highlights, 5 shadows, 6 whites, +// 7 blacks, 8 saturation, 9 look intensity, 10 faded film, 11 vibrance, 12 creative saturation, +// 13 tint balance pivot, 14..17 shadow tint, 17..20 highlight tint, 20..32 curves (master, red, +// green, blue × shadows / midtones / highlights), 32..41 wheels (shadows, midtones, highlights), +// 41 vignette amount, 42 vignette start, 43 roundness, 44 feather, 45 cx, 46 cy, 47 lw, 48 lh, +// 49 aspect. +@compute @workgroup_size(16, 16) +fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + var c = fxc_unpremul(px); + let temp = data[0]; + let tint = data[1]; + if (temp != 0.0 || tint != 0.0) { + c = vec3(c.x * (1.0 + 0.25 * temp), c.y * (1.0 - 0.25 * tint), c.z * (1.0 - 0.25 * temp)); + } + let expo = data[2]; + if (expo != 1.0) { + c = c * expo; + } + let contrast = data[3]; + if (contrast != 0.0) { + c = 0.5 + (c - 0.5) * (1.0 + contrast); + } + let hi = data[4]; + let sh = data[5]; + let wh = data[6]; + let bl = data[7]; + if (hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0) { + let l = max(luminance(c), 0.0); + let lt = min(l, 1.0); + let il = 1.0 - lt; + let d = 0.25 * hi * fxc_smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - fxc_smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (il * il); + c = c + d; + } + let l0 = luminance(c); + let sat = data[8]; + if (sat != 1.0) { + c = l0 + (c - l0) * sat; + } + let intensity = data[9]; + let faded = data[10]; + let vib = data[11]; + let csat = data[12]; + let st = fxc_d3(14u); + let ht = fxc_d3(17u); + let zero = vec3(0.0); + if (intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) { + var d = c; + if (faded != 0.0) { + d = d * (1.0 - 0.25 * faded) + 0.12 * faded; + } + let l = luminance(d); + if (vib != 0.0) { + let mx = max(max(d.x, d.y), d.z); + let mn = min(min(d.x, d.y), d.z); + var s = 0.0; + if (mx > 1e-6) { + s = (mx - mn) / mx; + } + let k = 1.0 + vib * (1.0 - clamp(s, 0.0, 1.0)); + d = l + (d - l) * k; + } + if (csat != 1.0) { + d = l + (d - l) * csat; + } + let pivot = data[13]; + let lt = clamp(l, 0.0, 1.0); + let wsh = 1.0 - fxc_smoothstep(0.0, pivot, lt); + let whi = fxc_smoothstep(pivot, 1.0, lt); + d = d + 0.5 * (st * wsh + ht * whi); + c = c + (d - c) * intensity; + } + for (var i = 0u; i < 3u; i++) { + c[i] = fxc_tone_curve(c[i], data[20], data[21], data[22]); + } + for (var i = 0u; i < 3u; i++) { + let b = 23u + i * 3u; + c[i] = fxc_tone_curve(c[i], data[b], data[b + 1u], data[b + 2u]); + } + let ws = fxc_d3(32u); + let wm = fxc_d3(35u); + let wh3 = fxc_d3(38u); + if (any(ws != zero) || any(wm != zero) || any(wh3 != zero)) { + let lt = clamp(luminance(c), 0.0, 1.0); + let ka = 1.0 - fxc_smoothstep(0.0, 0.5, lt); + let kz = fxc_smoothstep(0.5, 1.0, lt); + let km = 1.0 - ka - kz; + c = c + 0.5 * (ws * ka + wm * km + wh3 * kz); + } + let vig = data[41]; + if (vig != 0.0) { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let lw = data[47]; + let lh = data[48]; + var nx = (x - data[45]) / max(lw * 0.5, 1e-6); + var ny = (y - data[46]) / max(lh * 0.5, 1e-6); + let vround = data[43]; + let aspect = data[49]; + if (vround > 0.0) { + nx = nx * (1.0 + (max(aspect, 1.0) - 1.0) * vround); + ny = ny * (1.0 + (max(1.0 / aspect, 1.0) - 1.0) * vround); + } + let pw = 2.0 + max(-vround, 0.0) * 6.0; + let r = powz(powz(abs(nx), pw) + powz(abs(ny), pw), 1.0 / pw) / 1.4142135; + let start = data[42]; + let f = fxc_smoothstep(start, start + 0.05 + data[44] * (1.05 - start), r); + if (vig > 0.0) { + c = c + (1.0 - c) * min(vig * 0.2 * f, 1.0); + } else { + let k = 1.0 + vig * 0.2 * f; + c = c * max(k, 0.0); + } + } + c = max(c, zero); + textureStore(out, p, vec4(c * a, a)); +} + +// Straight-colour luminance plane (util::Plane::luma) in x. +@compute @workgroup_size(16, 16) +fn fxc_luma(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + textureStore(out, p, vec4(luminance(fxc_unpremul(px)), 0.0, 0.0, 0.0)); +} + +// Lumetri Sharpen: `src` + (luma − blurred luma `aux`.x) × amount. f[0].x = sharpen × 2. +@compute @workgroup_size(16, 16) +fn fxc_sharpen(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = fxc_unpremul(px); + let d = (luminance(c) - textureLoad(aux, p, 0).x) * P.f[0].x; + textureStore(out, p, vec4(max(c + d, vec3(0.0)) * a, a)); +} + +// ---------------------------------------------------------------- planes (util) + +// One morphology pass (util::minmax_row, edges repeated) of radius u[0].x along rows (u[0].y = 0) +// or columns; u[1] = per-channel operation: 0 min, 1 max, 2 copy. +@compute @workgroup_size(16, 16) +fn fxc_morph(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let dims = out_dims(); + let r = i32(P.u[0].x); + let vertical = P.u[0].y != 0u; + let modes = P.u[1]; + let centre = textureLoad(src, p, 0); + var lo = centre; + var hi = centre; + for (var k = -r; k <= r; k++) { + var q = p; + if (vertical) { + q.y = clamp(p.y + k, 0, dims.y - 1); + } else { + q.x = clamp(p.x + k, 0, dims.x - 1); + } + let v = textureLoad(src, q, 0); + lo = min(lo, v); + hi = max(hi, v); + } + var o = centre; + for (var i = 0; i < 4; i++) { + if (modes[i] == 0u) { + o[i] = lo[i]; + } else if (modes[i] == 1u) { + o[i] = hi[i]; + } + } + textureStore(out, p, o); +} + +// `src` + (`aux` − `src`) × f[0].x (Plane::zip_map lerp). +@compute @workgroup_size(16, 16) +fn fxc_lerp(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let x = textureLoad(src, p, 0); + let y = textureLoad(aux, p, 0); + textureStore(out, p, x + (y - x) * P.f[0].x); +} + +// ---------------------------------------------------------------- Key Light (keylight.rs) + +fn fxc_neutralise(c: vec3, bias: vec3) -> vec3 { + let l = max(luminance(bias), 1e-4); + return vec3(c.x * l / max(bias.x, 1e-4), c.y * l / max(bias.y, 1e-4), c.z * l / max(bias.z, 1e-4)); +} + +fn fxc_screen_diff(c: vec3, ix: vec3, bal: f32) -> f32 { + return c[ix.x] - (bal * c[ix.y] + (1.0 - bal) * c[ix.z]); +} + +fn fxc_correct(c0: vec3, sat: f32, contrast: f32, bright: f32) -> vec3 { + let l = luminance(c0); + let c = l + (c0 - l) * sat; + return ((c - 0.5) * contrast + 0.5) * bright; +} + +// Raw screen matte of `src` (the key source): out = (clipped, clipped, raw, 0). +// u[0].xyz = primary, other channels; f[0] = (alpha bias, screen difference); +// f[1] = (gain, balance, clip black, clip white). +@compute @workgroup_size(16, 16) +fn fxc_kl_raw(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let c = fxc_unpremul(textureLoad(src, p, 0)); + let d = fxc_screen_diff(fxc_neutralise(c, P.f[0].xyz), P.u[0].xyz, P.f[1].y); + let raw = clamp(1.0 - P.f[1].x * d / P.f[0].w, 0.0, 1.0); + let cb = P.f[1].z; + let m = clamp((raw - cb) / max(P.f[1].w - cb, 1e-4), 0.0, 1.0); + textureStore(out, p, vec4(m, m, raw, 0.0)); +} + +// Clip Rollback: `src` = (shrunk, grown) clipped matte, `aux` = raw planes (clipped, _, raw). +@compute @workgroup_size(16, 16) +fn fxc_kl_rollback(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let sg = textureLoad(src, p, 0); + let r = textureLoad(aux, p, 0); + var m = r.x; + if (sg.y > sg.x + 1e-4) { + m = min(max(m, r.z), sg.y); + } + textureStore(out, p, vec4(m, m, r.z, 0.0)); +} + +// Planes for the final pass: (screen matte from `src`.x, raw from `aux`.z, edge 0, 0). +@compute @workgroup_size(16, 16) +fn fxc_kl_pack(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + textureStore(out, p, vec4(textureLoad(src, p, 0).x, textureLoad(aux, p, 0).z, 0.0, 0.0)); +} + +// Combined matte from the screen matte and the source alpha (no inside / outside masks). +fn fxc_kl_combined(sm: f32, src_a: f32, mode: u32) -> f32 { + var a = sm; + let sa = clamp(src_a, 0.0, 1.0); + var ins = 0.0; + if (mode == 1u) { + ins = max(ins, sa); + } + a = max(a, ins); + if (mode == 2u) { + a = a * sa; + } + return clamp(a, 0.0, 1.0); +} + +// Edge band of the combined matte: `src` = source, `aux` = planes; u[0].x = source alpha mode. +@compute @workgroup_size(16, 16) +fn fxc_kl_band(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let a = fxc_kl_combined(textureLoad(aux, p, 0).x, textureLoad(src, p, 0).w, P.u[0].x); + textureStore(out, p, vec4(select(0.0, 1.0, a > 1e-3 && a < 0.999), 0.0, 0.0, 0.0)); +} + +// Planes `src` with the edge weight from the processed band `aux`.x; f[0].x = hardness. +@compute @workgroup_size(16, 16) +fn fxc_kl_edge(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let pl = textureLoad(src, p, 0); + let e = min(textureLoad(aux, p, 0).x * (1.0 + P.f[0].x * 4.0), 1.0); + textureStore(out, p, vec4(pl.x, pl.y, e, 0.0)); +} + +fn fxc_kl_replace(fg: vec3, c: vec3, method: u32, rc: vec3, amt: f32) -> vec3 { + if (amt <= 0.0) { + return fg; + } + switch method { + case 1u: { return fg + (c - fg) * amt; } + case 2u: { return rc; } + case 3u: { + let l = luminance(c) / max(luminance(rc), 1e-4); + return fg + (rc * l - fg) * amt; + } + default: { return fg; } + } +} + +// Final pass: `src` = source, `aux` = planes (screen matte, raw, edge). +// u[0] = (primary, others, view); u[1] = (replace method, foreground cc, unpremultiply, +// source alpha mode); f[0] = (screen, balance); f[1] = (despill bias, l0); f[2] = replace colour; +// f[3] = fg cc (sat, contrast, bright); f[4] = edge cc; f[5] = crops (l, r, t, b); +// f[6] = (offset, scale, _); f[7] = (layer w, layer h). +@compute @workgroup_size(16, 16) +fn fxc_kl_final(@builtin(global_invocation_id) gid: vec3) { + let p = fxc_pixel(gid); + if (p.x < 0) { + return; + } + let s = textureLoad(src, p, 0); + let pl = textureLoad(aux, p, 0); + let ix = P.u[0].xyz; + let view = P.u[0].w; + let c = fxc_unpremul(s); + let sm = pl.x; + let raw = pl.y; + let edge = pl.z; + let a = fxc_kl_combined(sm, s.w, P.u[1].w); + let screen = P.f[0].xyz; + let bal = P.f[0].w; + let dbias = P.f[1].xyz; + let fg_cc = P.u[1].y != 0u; + let ar = max(raw, 1e-4); + var fg = max((c - (1.0 - ar) * screen) / ar, vec3(0.0)); + let spill = max(fxc_screen_diff(fxc_neutralise(fg, dbias), ix, bal), 0.0); + fg[ix.x] -= spill / P.f[1].w; + let intermediate = fg; + fg = fxc_kl_replace(fg, c, P.u[1].x, P.f[2].xyz, max(sm - raw, 0.0)); + if (fg_cc) { + fg = fxc_correct(fg, P.f[3].x, P.f[3].y, P.f[3].z); + } + if (edge > 0.0) { + let ce = fxc_correct(fg, P.f[4].x, P.f[4].y, P.f[4].z); + fg = fg + (ce - fg) * edge; + } + var o: vec4; + switch view { + case 1u: { o = vec4(s.w, s.w, s.w, 1.0); } + case 2u: { + var cs = fxc_neutralise(c, dbias); + let s2 = max(fxc_screen_diff(cs, ix, bal), 0.0); + cs[ix.x] -= s2; + if (fg_cc) { + cs = fxc_correct(cs, P.f[3].x, P.f[3].y, P.f[3].z); + } + o = vec4(cs, 1.0); + } + case 3u: { o = vec4(edge, edge, edge, 1.0); } + case 4u: { o = vec4(sm, sm, sm, 1.0); } + case 5u, 6u: { o = vec4(0.0, 0.0, 0.0, 1.0); } + case 7u: { o = vec4(a, a, a, 1.0); } + case 8u: { + var v = 0.5; + if (a <= 1e-3) { + v = 0.0; + } else if (a >= 0.999) { + v = 1.0; + } + o = vec4(v, v, v, 1.0); + } + case 9u: { o = vec4(intermediate * sm, sm); } + default: { + let x = f32(p.x) + 0.5; + let y = f32(p.y) + 0.5; + let lx = (x - P.f[6].x) / P.f[6].z / P.f[7].x; + let ly = (y - P.f[6].y) / P.f[6].z / P.f[7].y; + let cr = P.f[5]; + let cropped = lx < cr.x || lx > 1.0 - cr.y || ly < cr.z || ly > 1.0 - cr.w; + if (cropped) { + o = vec4(0.0); + } else if (P.u[1].z != 0u) { + o = vec4(fg * a, a); + } else { + o = vec4(fg * a * a, a); + } + } + } + textureStore(out, p, o); +} diff --git a/crates/gpu/src/shaders/fx_distort.wgsl b/crates/gpu/src/shaders/fx_distort.wgsl new file mode 100644 index 00000000..a5493621 --- /dev/null +++ b/crates/gpu/src/shaders/fx_distort.wgsl @@ -0,0 +1,554 @@ +// GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. +// Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer pixels, as in util::remap. + +const DST_TAU: f32 = 6.2831855; +const DST_HALF_PI: f32 = 1.5707964; + +fn dst_centre(p: vec2) -> vec2 { + return vec2(f32(p.x) + 0.5, f32(p.y) + 0.5); +} + +fn dst_inside(p: vec2) -> bool { + let d = out_dims(); + return p.x < d.x && p.y < d.y; +} + +// ---------------------------------------------------------------- Twirl (distort::twirl) +// f[0] = (cx, cy, radius, angle in radians) +@compute @workgroup_size(16, 16) +fn dst_twirl(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + var s = q; + if (d < r) { + let t = 1.0 - d / r; + let a = -P.f[0].w * t * t; + let sn = sin(a); + let co = cos(a); + s = vec2(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Bulge (distort::bulge) +// f[0] = (cx, cy, rx, ry); f[1] = (height, taper, pin, edge); f[2] = layer rect (x, y, w, h) +@compute @workgroup_size(16, 16) +fn dst_bulge(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let rx = P.f[0].z; + let ry = P.f[0].w; + let dx = (q.x - c.x) / rx; + let dy = (q.y - c.y) / ry; + let d2 = dx * dx + dy * dy; + var s = q; + if (d2 < 1.0) { + var amt = P.f[1].x * pow(1.0 - d2, P.f[1].y) * 0.5; + if (P.f[1].z > 0.5) { + let l = P.f[2]; + let edge = P.f[1].w; + let ex = clamp(min(q.x - l.x - 0.5, l.x + l.z - 0.5 - q.x) / edge, 0.0, 1.0); + let ey = clamp(min(q.y - l.y - 0.5, l.y + l.w - 0.5 - q.y) / edge, 0.0, 1.0); + amt *= min(ex, ey); + } + let k = 1.0 - amt; + s = vec2(c.x + dx * rx * k, c.y + dy * ry * k); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Wave Warp (distort::wave_warp) + +fn dst_half_circle(u: f32) -> f32 { + let v = 2.0 * u - 1.0; + return sqrt(max(1.0 - v * v, 0.0)); +} + +fn dst_wave_noise(i: f32, seed: u32) -> f32 { + return hash_noise(bitcast(i32(i)), 0x5eedu, seed) * 2.0 - 1.0; +} + +// distort::wave_shape at the phase `cf + ci` in cycles (t / 2π), split into a fraction cf in +// [0, 2) and whole cycles ci so the fraction keeps full precision. +fn dst_wave_shape(kind: u32, cf: f32, ci: f32, seed: u32) -> f32 { + let ph = cf - floor(cf); + switch kind { + case 1u: { + return select(-1.0, 1.0, ph < 0.5); + } + case 2u: { + return 1.0 - 4.0 * abs(ph - 0.5); + } + case 3u: { + return 2.0 * ph - 1.0; + } + case 4u: { + if (ph < 0.5) { + return dst_half_circle(ph * 2.0); + } + return -dst_half_circle(ph * 2.0 - 1.0); + } + case 5u: { + return 2.0 * dst_half_circle(fract(ph * 2.0)) - 1.0; + } + case 6u: { + return 1.0 - 2.0 * dst_half_circle(fract(ph * 2.0)); + } + case 7u: { + return dst_wave_noise(floor(cf * 2.0) + 2.0 * ci, seed); + } + case 8u: { + let u = cf * 2.0; + let i = floor(u); + let f = u - i; + let s = f * f * (3.0 - 2.0 * f); + let n = i + 2.0 * ci; + return dst_wave_noise(n, seed) * (1.0 - s) + dst_wave_noise(n + 1.0, seed) * s; + } + default: { + return sin(ph * DST_TAU); + } + } +} + +// distort::wave_pin. +fn dst_wave_pin(pin: u32, x: f32, y: f32, r: vec4) -> f32 { + let u = clamp((x - r.x - 0.5) / max(r.z - 1.0, 1.0), 0.0, 1.0); + let v = clamp((y - r.y - 0.5) / max(r.w - 1.0, 1.0), 0.0, 1.0); + let horiz = clamp(min(v, 1.0 - v) * 2.0, 0.0, 1.0); + let vert = clamp(min(u, 1.0 - u) * 2.0, 0.0, 1.0); + switch pin { + case 1u: { return min(horiz, vert); } + case 2u: { return min(sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)) * 2.0, 1.0); } + case 3u: { return u; } + case 4u: { return v; } + case 5u: { return 1.0 - u; } + case 6u: { return 1.0 - v; } + case 7u: { return horiz; } + case 8u: { return vert; } + default: { return 1.0; } + } +} + +// u[0] = (wave type, pinning, seed, buffer width); f[0] = (dx, dy, height); f[2] = layer rect +// (x, y, w, h). data: the phase in cycles (along / width + phase / 2π) as per-column and per-row +// (fraction, whole cycles) pairs: fx (w), ix (w), fy (h), iy (h). +@compute @workgroup_size(16, 16) +fn dst_wavewarp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let dx = P.f[0].x; + let dy = P.f[0].y; + let w = P.u[0].w; + let x = u32(p.x); + let y = u32(p.y); + let cf = data[x] + data[2u * w + y]; + let ci = data[w + x] + data[2u * w + u32(out_dims().y) + y]; + let disp = dst_wave_shape(P.u[0].x, cf, ci, P.u[0].z) * P.f[0].z * dst_wave_pin(P.u[0].y, q.x, q.y, P.f[2]); + textureStore(out, p, sample_bilinear(src, q.x + dy * disp, q.y - dx * disp)); +} + +// ---------------------------------------------------------------- Ripple (distort::ripple) +// u[0].x = asymmetric; f[0] = (cx, cy, radius, wave width); f[1] = (wave height, phase mod 2π) +@compute @workgroup_size(16, 16) +fn dst_ripple(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + var s = q; + if (d < r && d != 0.0) { + let h = P.f[1].x; + let fall = 1.0 - d / r; + let arg = d / P.f[0].w * DST_TAU + P.f[1].y; + let o = sin(arg) * h * fall; + var t = 0.0; + if (P.u[0].x != 0u) { + t = cos(arg) * h * fall * 0.5; + } + let ux = dx / d; + let uy = dy / d; + s = vec2(q.x + ux * o - uy * t, q.y + uy * o + ux * t); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- CC Lens (distort2::cc_lens) +// f[0] = (cx, cy, radius, convergence exponent) +@compute @workgroup_size(16, 16) +fn dst_cclens(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let c = P.f[0].xy; + let r = P.f[0].z; + let dx = q.x - c.x; + let dy = q.y - c.y; + let d = sqrt(dx * dx + dy * dy); + if (d >= r) { + textureStore(out, p, vec4(0.0)); + return; + } + var s = q; + if (d >= 1e-9) { + let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w); + let k = r * g / d; + s = vec2(c.x + dx * k, c.y + dy * k); + } + textureStore(out, p, sample_bilinear(src, s.x, s.y)); +} + +// ---------------------------------------------------------------- Turbulent Displace (distort2) + +// distort2::fbm. u[0].w = octave count; f[1].w = fraction of the last octave; f[3].y = falloff. +fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 { + let n = P.u[0].w; + let frac = P.f[1].w; + let falloff = P.f[3].y; + var sum = 0.0; + var norm = 0.0; + var amp = 1.0; + var f = 1.0; + for (var o = 0u; o < n; o++) { + var w = 1.0; + if (o + 1u == n && frac > 0.0) { + w = frac; + } + sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w; + norm += amp * w; + amp *= falloff; + f *= 2.0; + } + return sum / max(norm, 1e-6); +} + +// The centred noise `n(u, v, s)` (cross-faded over the evolution cycle when u[1].x is set). +// f[1] = (z, z - cycle, cycle position t). +fn dst_turb_n(u: f32, v: f32, s: u32) -> f32 { + let seed = P.u[0].z + s; + if (P.u[1].x != 0u) { + let t = P.f[1].z; + return dst_fbm(u, v, P.f[1].x, seed) * (1.0 - t) + dst_fbm(u, v, P.f[1].y, seed) * t - 0.5; + } + return dst_fbm(u, v, P.f[1].x, seed) - 0.5; +} + +// u[0] = (displacement kind, pinning, seed, octaves); u[1].x = cycle evolution; +// f[0] = (offset x, offset y, size, amount); f[1] = (z0, z1, t, last-octave fraction); +// f[2] = layer rect (x, y, w, h); f[3] = (edge, falloff) +@compute @workgroup_size(16, 16) +fn dst_turbulent(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let kind = P.u[0].x; + let amount = P.f[0].w; + let u = (q.x - P.f[0].x) / P.f[0].z; + let v = (q.y - P.f[0].y) / P.f[0].z; + var d = vec2(0.0); + switch kind { + case 1u, 2u, 4u, 5u: { + let e = 0.05; + let gx = (dst_turb_n(u + e, v, 0u) - dst_turb_n(u - e, v, 0u)) / (2.0 * e); + let gy = (dst_turb_n(u, v + e, 0u) - dst_turb_n(u, v - e, 0u)) / (2.0 * e); + let k = amount * 0.25; + if (kind == 1u || kind == 4u) { + d = vec2(gx * k, gy * k); + } else { + d = vec2(-gy * k, gx * k); + } + } + case 6u: { + d = vec2(0.0, dst_turb_n(u, v, 17u) * amount); + } + case 7u: { + d = vec2(dst_turb_n(u, v, 0u) * amount, 0.0); + } + case 8u: { + let a = dst_turb_n(u, v, 0u) * amount; + d = vec2(a, a); + } + default: { + d = vec2(dst_turb_n(u, v, 0u) * amount, dst_turb_n(u, v, 17u) * amount); + } + } + let r = P.f[2]; + let edge = P.f[3].x; + let l = clamp((q.x - r.x - 0.5) / edge, 0.0, 1.0); + let rr = clamp((r.x + r.z - 0.5 - q.x) / edge, 0.0, 1.0); + let t = clamp((q.y - r.y - 0.5) / edge, 0.0, 1.0); + let bt = clamp((r.y + r.w - 0.5 - q.y) / edge, 0.0, 1.0); + var k = 1.0; + switch P.u[0].y { + case 1u: { k = min(min(l, rr), min(t, bt)); } + case 2u: { k = min(t, bt); } + case 3u: { k = min(l, rr); } + case 4u: { k = l; } + case 5u: { k = t; } + case 6u: { k = rr; } + case 7u: { k = bt; } + default: {} + } + textureStore(out, p, sample_bilinear(src, q.x + d.x * k, q.y + d.y * k)); +} + +// ---------------------------------------------------------------- Displacement Map (distort2) + +// util::pick for a straight colour (source index in util::SRC_ORDER). +fn dst_pick(s: u32, c: vec3, a: f32) -> f32 { + switch s { + case 0u: { return c.x; } + case 1u: { return c.y; } + case 2u: { return c.z; } + case 3u: { return a; } + case 4u: { return luminance(c); } + case 5u: { return rgb_to_hsl(c).x; } + case 6u: { return rgb_to_hsl(c).z; } + case 7u: { return rgb_to_hsl(c).y; } + case 8u: { return 1.0; } + case 9u: { return 0.5; } + default: { return 0.0; } + } +} + +// u[0] = (horizontal source, vertical source, map mode: 0 self, 1 fitted layer (transparent +// outside), 2 tiled layer (edges repeat), flags: 1 wrap, 2 Center Map bounds); +// u[1] = (buffer width, buffer height); f[0] = (max horizontal, max vertical). +// data: per-column map x, per-row map y (the map layer's buffer pixels), per-column and +// per-row "inside the centred map" flags. aux = the map layer. +@compute @workgroup_size(16, 16) +fn dst_displace(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[1].x; + let h = P.u[1].y; + let x = u32(p.x); + let y = u32(p.y); + let px = textureLoad(src, p, 0); + let flags = P.u[0].w; + if ((flags & 2u) != 0u && (data[w + h + x] < 0.5 || data[2u * w + h + y] < 0.5)) { + textureStore(out, p, px); + return; + } + var m = px; + let mode = P.u[0].z; + if (mode == 1u) { + m = sample_bilinear(aux, data[x], data[w + y]); + } else if (mode == 2u) { + m = sample_bilinear_clamped(aux, data[x], data[w + y]); + } + var c = vec3(0.0); + let a = m.w; + if (a > 1e-6) { + c = m.xyz / a; + } + let al = max(a, 0.0); + var dx = 0.0; + var dy = 0.0; + if (P.u[0].x != 10u) { + dx = (dst_pick(P.u[0].x, c, al) - 0.5) * 2.0 * P.f[0].x; + } + if (P.u[0].y != 10u) { + dy = (dst_pick(P.u[0].y, c, al) - 0.5) * 2.0 * P.f[0].y; + } + if (dx == 0.0 && dy == 0.0) { + textureStore(out, p, px); + return; + } + var sx = f32(p.x) + 0.5 + dx; + var sy = f32(p.y) + 0.5 + dy; + if ((flags & 1u) != 0u) { + let fw = f32(w); + let fh = f32(h); + sx = sx - floor(sx / fw) * fw; + sy = sy - floor(sy / fh) * fh; + textureStore(out, p, sample_bilinear_clamped(src, sx, sy)); + } else { + textureStore(out, p, sample_bilinear(src, sx, sy)); + } +} + +// ---------------------------------------------------------------- Mesh Warp (distort2::grid_warp) +// u[0] = (row starts offset, list offset). data: 12 floats per triangle (the barycentric +// coefficients k11 k12 k21 k22 relative to vertex c, c, then the source points a, b, c), then +// per output row the start of its triangle list (height + 1 entries), then the lists (triangle +// indices in the CPU's bucket order). +@compute @workgroup_size(16, 16) +fn dst_mesh(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let rs = P.u[0].x; + let lo = P.u[0].y; + let i0 = u32(data[rs + u32(p.y)]); + let i1 = u32(data[rs + u32(p.y) + 1u]); + var cur = vec4(0.0); + for (var i = i0; i < i1; i++) { + if (cur.w > 0.0) { + break; + } + let b = u32(data[lo + i]) * 12u; + let ex = q.x - data[b + 4u]; + let ey = q.y - data[b + 5u]; + let l1 = data[b] * ex + data[b + 1u] * ey; + let l2 = data[b + 2u] * ex + data[b + 3u] * ey; + let l3 = 1.0 - l1 - l2; + // The CPU tests against -1e-9 in f64; f32 rounding needs a wider margin so pixels on + // shared edges are not dropped by both triangles. + let e = -1e-5; + if (l1 < e || l2 < e || l3 < e) { + continue; + } + let sx = data[b + 6u] * l1 + data[b + 8u] * l2 + data[b + 10u] * l3; + let sy = data[b + 7u] * l1 + data[b + 9u] * l2 + data[b + 11u] * l3; + cur = sample_bilinear(src, sx, sy); + } + textureStore(out, p, cur); +} + +// ---------------------------------------------------------------- Mosaic (misc::mosaic) +// data: column tile index (w), row tile index (h), tile column bounds (nx + 1), tile row bounds +// (ny + 1); u[0] = (w, h, nx, ny). + +// Row sums of each tile's columns: out is nx × h. +@compute @workgroup_size(16, 16) +fn dst_mosaic_rows(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].y; + let xb = w + h + u32(p.x); + var s = vec4(0.0); + for (var x = u32(data[xb]); x < u32(data[xb + 1u]); x++) { + s += textureLoad(src, vec2(i32(x), p.y), 0); + } + textureStore(out, p, s); +} + +// Tile averages: out is nx × ny (src = the row sums). +@compute @workgroup_size(16, 16) +fn dst_mosaic_tiles(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let w = P.u[0].x; + let h = P.u[0].y; + let xb = w + h + u32(p.x); + let yb = w + h + P.u[0].z + 1u + u32(p.y); + let y0 = u32(data[yb]); + let y1 = u32(data[yb + 1u]); + var s = vec4(0.0); + for (var y = y0; y < y1; y++) { + s += textureLoad(src, vec2(p.x, i32(y)), 0); + } + let n = f32((u32(data[xb + 1u]) - u32(data[xb])) * (y1 - y0)); + textureStore(out, p, s / max(n, 1.0)); +} + +// Each pixel takes its tile's average (src = the tile averages). +@compute @workgroup_size(16, 16) +fn dst_mosaic_out(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let tx = i32(data[u32(p.x)]); + let ty = i32(data[P.u[0].x + u32(p.y)]); + textureStore(out, p, textureLoad(src, vec2(tx, ty), 0)); +} + +// Sharp Colors: data = per-column / per-row source pixel; u[0].x = w. +@compute @workgroup_size(16, 16) +fn dst_mosaic_sharp(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let sx = i32(data[u32(p.x)]); + let sy = i32(data[P.u[0].x + u32(p.y)]); + textureStore(out, p, tex_get_clamped(src, sx, sy)); +} + +// ---------------------------------------------------------------- Find Edges / Emboss (misc) + +fn dst_blend_with(orig: vec4, fx: vec4, k: f32) -> vec4 { + return fx + (orig - fx) * k; +} + +fn dst_lum_at(x: i32, y: i32) -> f32 { + return luminance(tex_get_clamped(src, x, y).xyz); +} + +// u[0].x = invert; f[0].x = blend with original. +@compute @workgroup_size(16, 16) +fn dst_findedges(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let x = p.x; + let y = p.y; + let gx = dst_lum_at(x + 1, y - 1) + 2.0 * dst_lum_at(x + 1, y) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x - 1, y) + - dst_lum_at(x - 1, y + 1); + let gy = dst_lum_at(x - 1, y + 1) + 2.0 * dst_lum_at(x, y + 1) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x, y - 1) + - dst_lum_at(x + 1, y - 1); + let e = min(sqrt(gx * gx + gy * gy), 1.0); + var v = 1.0 - e; + if (P.u[0].x != 0u) { + v = e; + } + let px = textureLoad(src, p, 0); + let a = px.w; + textureStore(out, p, dst_blend_with(px, vec4(v * a, v * a, v * a, a), P.f[0].x)); +} + +// f[0] = (dx, dy, contrast, blend with original). +@compute @workgroup_size(16, 16) +fn dst_emboss(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if (!dst_inside(p)) { + return; + } + let q = dst_centre(p); + let d = P.f[0].xy; + let a = sample_bilinear_clamped(src, q.x + d.x, q.y + d.y); + let c = sample_bilinear_clamped(src, q.x - d.x, q.y - d.y); + let l = luminance(a.xyz) - luminance(c.xyz); + let v = clamp(0.5 + l * P.f[0].z, 0.0, 1.0); + let px = textureLoad(src, p, 0); + let al = px.w; + textureStore(out, p, dst_blend_with(px, vec4(v * al, v * al, v * al, al), P.f[0].w)); +} diff --git a/crates/gpu/src/shaders/fx_generate.wgsl b/crates/gpu/src/shaders/fx_generate.wgsl new file mode 100644 index 00000000..e5e2f411 --- /dev/null +++ b/crates/gpu/src/shaders/fx_generate.wgsl @@ -0,0 +1,631 @@ +// GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in +// effectcraft-effects operation for operation; parameter layouts are documented per entry. +// +// Hard edges (wipes) take per-column and per-row terms computed in f64 on the CPU and uploaded +// as (hi, lo) f32 pairs in `data`: near an edge the hi parts cancel exactly, so the edge sits +// where the CPU's f64 arithmetic puts it. + +const GEN_DEG: f32 = 57.29577951308232; + +// util::unpremul: (straight colour, alpha). +fn gen_unpremul(p: vec4) -> vec4 { + if (p.w > 1e-6) { + return vec4(p.xyz / p.w, p.w); + } + return vec4(0.0, 0.0, 0.0, max(p.w, 0.0)); +} + +// util::smoothstep. +fn gen_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); +} + +// generate::gen_blend: mode = index into BlendMode::ALL, 255 = None (replace). +fn gen_blend(o: vec4, g: vec4, mode: u32) -> vec4 { + if (mode == 255u) { + return g; + } + return blend_pixel(mode, o, g, 0.5); +} + +// A (hi, lo) pair from `data`. +fn gen_hl(i: u32) -> vec2 { + return vec2(data[i], data[i + 1u]); +} + +// (a + b) for (hi, lo) pairs whose hi parts cancel near zero. +fn gen_sum(a: vec2, b: vec2) -> f32 { + return (a.x + b.x) + (a.y + b.y); +} + +// ---------------------------------------------------------------- Radial Blur (raster::radial_blur) + +// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom). +@compute @workgroup_size(16, 16) +fn gen_radial_blur(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let zoom = P.u[0].x != 0u; + let n = P.u[0].y; + let c = P.f[0].xy; + let v = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + var acc = vec4(0.0); + for (var i = 0u; i < n; i++) { + var s: vec2; + if (zoom) { + let k = data[i]; + s = vec2(c.x + v.x * k, c.y + v.y * k); + } else { + let sn = data[2u * i]; + let cs = data[2u * i + 1u]; + s = vec2(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs); + } + acc += sample_bilinear(src, s.x, s.y); + } + textureStore(out, p, acc / f32(n)); +} + +// ---------------------------------------------------------------- CC Radial Fast Blur + +// u[0].x = zoom mode (0 standard, 1 brightest, 2 darkest); f[0] = (cx, cy, s). +@compute @workgroup_size(16, 16) +fn gen_ccradialfast(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let mode = P.u[0].x; + let c = P.f[0].xy; + let s = P.f[0].z; + let d = vec2(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y); + let len = sqrt(d.x * d.x + d.y * d.y) * s; + let n = clamp(u32(ceil(len)), 2u, 64u); + var acc = vec4(0.0); + if (mode == 1u) { + acc = vec4(-3.0e38); + } else if (mode == 2u) { + acc = vec4(3.0e38); + } + for (var i = 0u; i < n; i++) { + let k = 1.0 - s * f32(i) / f32(n - 1u); + let q = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k); + if (mode == 1u) { + acc = max(acc, q); + } else if (mode == 2u) { + acc = min(acc, q); + } else { + acc += q / f32(n); + } + } + let a = acc.w; + textureStore(out, p, vec4(max(min(acc.xyz, vec3(a)), vec3(0.0)), clamp(a, 0.0, 1.0))); +} + +// ---------------------------------------------------------------- Camera Lens Blur + +// Linear working space and highlight boost before the blur. +// u[0] = (linear, boost); f[0] = (threshold, boost, saturation). +@compute @workgroup_size(16, 16) +fn gen_lens_pre(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var px = textureLoad(src, p, 0); + if (P.u[0].x != 0u) { + let u = gen_unpremul(px); + px = vec4(srgb_to_linear(u.x) * u.w, srgb_to_linear(u.y) * u.w, srgb_to_linear(u.z) * u.w, u.w); + } + if (P.u[0].y != 0u) { + let u = gen_unpremul(px); + let c = u.xyz; + let a = u.w; + if (a > 0.0 && luminance(c) >= P.f[0].x) { + let boost = P.f[0].y; + let sat = P.f[0].z; + let m = max(max(c.x, c.y), c.z); + let tinted = c + (m - c) * (1.0 - sat); + px = vec4((c + tinted * (boost - 1.0)) * a, px.w); + } + } + textureStore(out, p, px); +} + +// Per-row prefix sums: out(x + 1, y) = sum of src(0..=x, y), out(0, y) = 0. One thread per row. +@compute @workgroup_size(64, 1) +fn gen_row_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x++) { + acc += textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +// The iris sum. data = spans (dy, left, right); aux = row prefix sums (when u[0].w). +// u[0] = (spans, repeat, linear, prefix); f[0].x = 1 / pixel count. +@compute @workgroup_size(16, 16) +fn gen_lens_blur(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let n = P.u[0].x; + let repeat = P.u[0].y != 0u; + let prefix = P.u[0].w != 0u; + let w = dims.x; + let h = dims.y; + var acc = vec4(0.0); + for (var s = 0u; s < n; s++) { + var yy = p.y + i32(data[3u * s]); + if (yy < 0 || yy >= h) { + if (!repeat) { + continue; + } + yy = clamp(yy, 0, h - 1); + } + var a = p.x + i32(data[3u * s + 1u]); + var bnd = p.x + i32(data[3u * s + 2u]); + if (!prefix) { + for (var i = a; i <= bnd; i++) { + if (repeat) { + acc += textureLoad(src, vec2(clamp(i, 0, w - 1), yy), 0); + } else if (i >= 0 && i < w) { + acc += textureLoad(src, vec2(i, yy), 0); + } + } + continue; + } + if (repeat) { + if (a < 0) { + let k = min(-a, bnd - a + 1); + acc += textureLoad(src, vec2(0, yy), 0) * f32(k); + a = 0; + } + if (bnd >= w) { + let k = min(bnd - (w - 1), bnd - a + 1); + acc += textureLoad(src, vec2(w - 1, yy), 0) * f32(k); + bnd = w - 1; + } + } else { + a = max(a, 0); + bnd = min(bnd, w - 1); + } + if (a <= bnd) { + acc += textureLoad(aux, vec2(bnd + 1, yy), 0) - textureLoad(aux, vec2(a, yy), 0); + } + } + var px = acc * P.f[0].x; + px.w = clamp(px.w, 0.0, 1.0); + if (P.u[0].z != 0u) { + let u = gen_unpremul(px); + px = vec4(linear_to_srgb(max(u.x, 0.0)) * u.w, linear_to_srgb(max(u.y, 0.0)) * u.w, linear_to_srgb(max(u.z, 0.0)) * u.w, u.w); + } + textureStore(out, p, px); +} + +// ---------------------------------------------------------------- wipes + +// Linear Wipe. data = per column ((x + 0.5)·dx − edge) (hi, lo), then per row ((y + 0.5)·dy) +// (hi, lo); u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let d = gen_sum(gen_hl(2u * u32(p.x)), gen_hl(P.u[0].x + 2u * u32(p.y))); + let k = clamp(d / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as +// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w. +// u[0].x = row table offset; f[0].x = feather. +@compute @workgroup_size(16, 16) +fn gen_venetian(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let col = 6u * u32(p.x); + let row = gen_hl(P.u[0].x + 2u * u32(p.y)); + // (d mod w) wraps once the column and row terms reach w. + let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0; + var t: f32; + if (wrapped) { + t = gen_sum(gen_hl(col + 2u), row); + } else { + t = gen_sum(gen_hl(col), row); + } + let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start, +// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe. +fn gen_psi(r: u32, x: u32, y: u32) -> f32 { + let c = 9u * x + 3u * r; + let w = P.u[0].x + 9u * y + 3u * r; + // x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R. + let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]); + let yr = data[w + 2u] + data[c + 2u]; + return atan2(xr, yr) * GEN_DEG; +} + +// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R), +// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe); +// f[0] = (edge, feather). +@compute @workgroup_size(16, 16) +fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let x = u32(p.x); + let y = u32(p.y); + let dir = P.u[0].y; + let edge = P.f[0].x; + var a = gen_psi(0u, x, y); + if (a < 0.0) { + a += 360.0; + } + var base: f32; + if (dir == 0u || (dir == 2u && a <= 180.0)) { + base = a - edge; + if (abs(base) < 90.0) { + base = gen_psi(1u, x, y); + } + } else { + base = (360.0 - a) - edge; + if (abs(base) < 90.0) { + base = -gen_psi(2u, x, y); + } + } + let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0); + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness). +@compute @workgroup_size(16, 16) +fn gen_gradient_wipe(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let done = P.f[0].x; + let soft = P.f[0].y; + let g = gen_unpremul(textureLoad(aux, p, 0)); + var t = clamp(luminance(g.xyz), 0.0, 1.0); + if (P.u[0].x != 0u) { + t = 1.0 - t; + } + var k = 0.0; + if (done >= 1.0) { + k = 0.0; + } else if (soft > 0.0) { + k = clamp((t - (done * (1.0 + soft) - soft)) / soft, 0.0, 1.0); + } else if (t >= done) { + k = 1.0; + } + textureStore(out, p, textureLoad(src, p, 0) * k); +} + +// ---------------------------------------------------------------- Cell Pattern (generate2) + +// Worley distances for (u, v) in cell units: (F1, F2, hash of the nearest cell). +fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3 { + let tile = P.u[1].x != 0u; + let nx = i32(P.u[1].y); + let ny = i32(P.u[1].z); + let cx = i32(floor(u)); + let cy = i32(floor(v)); + var f1 = 3.0e38; + var f2 = 3.0e38; + var id = 0.0; + for (var j = -1; j <= 1; j++) { + for (var i = -1; i <= 1; i++) { + let gx = cx + i; + let gy = cy + j; + var tx = gx; + var ty = gy; + if (tile) { + tx = ((gx % nx) + nx) % nx; + ty = ((gy % ny) + ny) % ny; + } + let hx = bitcast(tx); + let hy = bitcast(ty); + let h1 = hash_noise(hx, hy, seed); + let h2 = hash_noise(hx, hy, seed + 1u); + let h3 = hash_noise(hx, hy, seed + 2u); + let ang = 6.283185307179586 * (h3 + evo); + let px = f32(gx) + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * cos(ang); + let py = f32(gy) + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * sin(ang); + let d = sqrt((px - u) * (px - u) + (py - v) * (py - v)); + if (d < f1) { + f2 = f1; + f1 = d; + id = hash_noise(hx, hy, seed + 3u); + } else if (d < f2) { + f2 = d; + } + } + } + return vec3(f1, f2, id); +} + +fn gen_cell_value(pattern: u32, f1: f32, f2: f32, id: f32) -> f32 { + let e = f2 - f1; + switch pattern { + case 0u: { return clamp(1.0 - f1 * 1.3, 0.0, 1.0); } + case 1u: { return id * 0.8 + 0.2 * clamp(1.0 - f1, 0.0, 1.0); } + case 2u: { return clamp(e * 2.0, 0.0, 1.0); } + case 3u: { return id * gen_smoothstep(0.0, 0.06, e); } + case 4u: { return id; } + case 5u: { return clamp(1.0 - f1 * f1 * 2.0, 0.0, 1.0); } + case 6u: { return id * clamp(e * 3.0, 0.0, 1.0); } + default: { return 1.0 - clamp(abs(e - 0.15) * 5.0, 0.0, 1.0); } + } +} + +fn gen_overflow(v: f32, mode: u32) -> f32 { + if (mode == 1u) { + return 0.5 + 0.5 * tanh((v - 0.5) * 2.0); + } + if (mode == 2u) { + let t = v - 2.0 * floor(v * 0.5); + return select(t, 2.0 - t, t > 1.0); + } + return clamp(v, 0.0, 1.0); +} + +// u[0] = (pattern kind, overflow, invert, seed); u[1] = (tile, columns, rows); +// f[0] = (contrast, disperse, size, evolution mod 1); f[1] = (0.5 − offset x, 0.5 − offset y). +@compute @workgroup_size(16, 16) +fn gen_cell_pattern(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let size = P.f[0].z; + let u = (f32(p.x) + P.f[1].x) / size; + let v = (f32(p.y) + P.f[1].y) / size; + let w = gen_worley(u, v, P.f[0].y, P.f[0].w, P.u[0].w); + var val = gen_cell_value(P.u[0].x, w.x, w.y, w.z); + val = gen_overflow((val - 0.5) * P.f[0].x + 0.5, P.u[0].y); + if (P.u[0].z != 0u) { + val = 1.0 - val; + } + textureStore(out, p, vec4(val, val, val, 1.0)); +} + +// ---------------------------------------------------------------- Checkerboard / Grid / 4-Color + +// Signed distance (in feather units) to the nearest cell edge, positive in even cells. +fn gen_axis(q: f32, size: f32, f: f32) -> f32 { + let u = q / size; + let fl = floor(u); + let fr = u - fl; + let d = min(fr, 1.0 - fr) * size; + let n = i32(fl); + let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0); + return s * min(d / (f * 0.5), 1.0); +} + +// u[0].x = mode; f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_checker(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let sx = gen_axis(f32(p.x) + P.f[0].x, P.f[0].z, P.f[1].x); + let sy = gen_axis(f32(p.y) + P.f[0].y, P.f[0].w, P.f[1].y); + let cov = clamp(0.5 + 0.5 * sx * sy, 0.0, 1.0) * P.f[1].z; + let c = P.f[2].xyz; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * cov, cov), P.u[0].x)); +} + +// f32::rem_euclid. +fn gen_rem(x: f32, m: f32) -> f32 { + let r = x - m * trunc(x / m); + return select(r, r + m, r < 0.0); +} + +// u[0] = (mode, invert); f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h); +// f[1] = (feather w, feather h, opacity, border); f[2] = colour. +@compute @workgroup_size(16, 16) +fn gen_grid(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let w = P.f[0].z; + let h = P.f[0].w; + let border = P.f[1].w; + let gx = gen_rem(f32(p.x) + P.f[0].x, w); + let gy = gen_rem(f32(p.y) + P.f[0].y, h); + let dx = min(gx, w - gx); + let dy = min(gy, h - gy); + let cx = clamp((border / 2.0 - dx) / P.f[1].x + 0.5, 0.0, 1.0); + let cy = clamp((border / 2.0 - dy) / P.f[1].y + 0.5, 0.0, 1.0); + var cov = 0.0; + if (border > 0.0) { + cov = max(cx, cy); + } + if (P.u[0].y != 0u) { + cov = 1.0 - cov; + } + let a = cov * P.f[1].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(P.f[2].xyz * a, a), P.u[0].x)); +} + +// u[0] = (mode, seed); f[0..4] = points (x, y); f[4..8] = colours; +// f[8] = (blend · 100, jitter, opacity). +@compute @workgroup_size(16, 16) +fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let bl = P.f[8].x; + var acc = vec3(0.0); + var wsum = 0.0; + for (var i = 0; i < 4; i++) { + let q = P.f[i].xy; + let dx = f32(p.x) + 0.5 - q.x; + let dy = f32(p.y) + 0.5 - q.y; + let d2 = dx * dx + dy * dy; + let w = 1.0 / pow(d2 / bl + 1e-6, 1.5); + acc += P.f[4 + i].xyz * w; + wsum += w; + } + var c = acc / wsum; + let jitter = P.f[8].y; + if (jitter > 0.0) { + let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0); + c += vec3(n); + } + let op = P.f[8].z; + textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4(c * op, op), P.u[0].x)); +} + +// ---------------------------------------------------------------- Noise / Add Grain + +// Noise (misc::noise). u[0] = (frame seed, colour, clip, width); f[0].x = amount. +@compute @workgroup_size(16, 16) +fn gen_noise(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var px = textureLoad(src, p, 0); + let a = px.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let seed = P.u[0].x; + let w = P.u[0].w; + let amt = P.f[0].x; + let x = u32(p.x); + let y = u32(p.y); + var n = vec3((hash_noise(x, y, seed) - 0.5) * amt); + if (P.u[0].y != 0u) { + n.y = (hash_noise(x + w, y, seed) - 0.5) * amt; + n.z = (hash_noise(x + 2u * w, y, seed) - 0.5) * amt; + } + if (P.u[0].z != 0u) { + px = vec4(clamp(px.xyz + n * a, vec3(0.0), vec3(a)), a); + } else { + // Unclipped: values past black / white wrap around. + px = vec4(fract_euclid(px.x / a + n.x) * a, fract_euclid(px.y / a + n.y) * a, fract_euclid(px.z / a + n.z) * a, a); + } + textureStore(out, p, px); +} + +// Add Grain's noise planes (GrainLook::planes before Softness): rgb = per-channel zero-mean +// value noise. u[0] = (mono, seed, salt); f[0].xyz = x divisors (size · aspect), f[0].w = frame; +// f[1].xyz = y divisors (size). +@compute @workgroup_size(16, 16) +fn gen_grain_planes(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let x = f32(p.x); + let y = f32(p.y); + let frame = P.f[0].w; + let seed = P.u[0].y; + let salt = P.u[0].z; + let n0 = value_noise(x / P.f[0].x, y / P.f[1].x, frame, seed) - 0.5; + var g = vec3(n0); + if (P.u[0].x == 0u) { + g.y = value_noise(x / P.f[0].y, y / P.f[1].y, frame, seed + salt) - 0.5; + g.z = value_noise(x / P.f[0].z, y / P.f[1].z, frame, seed + 2u * salt) - 0.5; + } + textureStore(out, p, vec4(g, 0.0)); +} + +// GrainLook::blend. +fn gen_grain_blend(mode: u32, c: f32, n: f32) -> f32 { + var v: f32; + switch mode { + case 1u: { v = c * (1.0 + n); } + case 2u: { v = c + n; } + case 3u: { v = 1.0 - (1.0 - c) * (1.0 - abs(n)); } + case 4u: { v = c + n * 4.0 * c * (1.0 - c); } + default: { v = c + n * (1.0 - 0.5 * c); } + } + return max(v, 0.0); +} + +// Add Grain applied: aux = noise planes. u[0] = (mono, mode, tint); f[0] = (amp, saturation, +// midpoint); f[1].xyz = tint factors; f[2].xyz = channel intensities; f[3].xyz = tone weights. +@compute @workgroup_size(16, 16) +fn gen_grain_apply(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let px = textureLoad(src, p, 0); + let u = gen_unpremul(px); + let a = u.w; + if (a <= 0.0) { + textureStore(out, p, px); + return; + } + let c = u.xyz; + var g = textureLoad(aux, p, 0).xyz; + if (P.u[0].x != 0u) { + g = vec3(g.x); + } else { + let gm = (g.x + g.y + g.z) / 3.0; + g = gm + (g - gm) * P.f[0].y; + } + if (P.u[0].z != 0u) { + g = g * P.f[1].xyz; + } + let mid = P.f[0].z; + let l = clamp(luminance(c), 0.0, 1.0); + let s = 1.0 - gen_smoothstep(0.0, mid, l); + let hi = gen_smoothstep(mid, 1.0, l); + let tones = P.f[3].xyz; + let weight = tones.x * s + tones.y * (1.0 - s - hi) + tones.z * hi; + let amp = P.f[0].x; + let ci = P.f[2].xyz; + let mode = P.u[0].y; + let o = vec3( + gen_grain_blend(mode, c.x, g.x * amp * ci.x * weight), + gen_grain_blend(mode, c.y, g.y * amp * ci.y * weight), + gen_grain_blend(mode, c.z, g.z * amp * ci.z * weight), + ); + textureStore(out, p, vec4(o * a, a)); +} diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index c94076a0..443abcf9 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -828,3 +828,19 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3) { } textureStore(out, p, px); } + +// Adjustment layer finish (Renderer::draw_adjustment): canvas `src` moves toward the adjusted +// comp `aux` by the footprint's alpha × opacity. The footprint is `data` (RGBA f32 rows of +// u[0].x pixels); f[0].x = opacity. +@compute @workgroup_size(16, 16) +fn adjust_mix(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let c = textureLoad(src, p, 0); + let a = textureLoad(aux, p, 0); + let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x; + textureStore(out, p, c + (a - c) * k); +} diff --git a/crates/gpu/src/shaders/particles.wgsl b/crates/gpu/src/shaders/particles.wgsl new file mode 100644 index 00000000..694d6fb6 --- /dev/null +++ b/crates/gpu/src/shaders/particles.wgsl @@ -0,0 +1,270 @@ +// GPU particles: effects::psim systems, one invocation per particle id. Mirrors the CPU step +// (effects::sim::Phys::spawn / step, effects::sim3::Pg::spawn / step) operation for operation. +// +// Concatenated after common.wgsl and kernels.wgsl (hash_noise, value_noise, powz); bindings in +// group 1 (group 0 is unused here). + +struct PSim { + // Particles, from step, to step, system (0 engine, 1 cannon). + u: vec4, + // Animation, flat, seed, particles in `prev`. + u2: vec4, + // Engine: f[0] = (life, speed, extra, cone), f[1] = producer, f[2] = radius, + // f[3] = gravity, f[4] = axis, f[5] = (damp, dt). + // Cannon: f[0] = (pos.x, pos.y, barrel angle, barrel radius), f[1] = (dir spread, vel, + // vel spread, grav spread), f[2] = (gravity.x, gravity.y, dt), f[3] = bounds. + f: array, 6>, +} + +struct PState { + // Position, age. + p: vec4, + // Velocity, life. + v: vec4, + // rnd, alive (1 / 0), id. + m: vec4, +} + +@group(1) @binding(0) var S: PSim; +@group(1) @binding(1) var births: array; +@group(1) @binding(2) var prev: array; +@group(1) @binding(3) var next: array; + +const PS_TAU: f32 = 6.2831855; + +fn ps_h(i: u32, k: u32) -> f32 { + return hash_noise(i, k, S.u2.z); +} + +fn ps_hs(i: u32, k: u32) -> f32 { + return hash_noise(i, k, S.u2.z) * 2.0 - 1.0; +} + +// sim::norm3. +fn ps_norm3(v: vec3) -> vec3 { + let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z); + if (l > 1e-9) { + return v / l; + } + return vec3(0.0, -1.0, 0.0); +} + +// sim::rand_dir. +fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3 { + if (flat) { + let a = ps_h(i, k) * PS_TAU; + return vec3(cos(a), sin(a), 0.0); + } + let z = ps_hs(i, k); + let a = ps_h(i, k + 1u) * PS_TAU; + let r = sqrt(max(1.0 - z * z, 0.0)); + return vec3(r * cos(a), r * sin(a), z); +} + +struct Part { + p: vec3, + v: vec3, + age: f32, + life: f32, + rnd: f32, +} + +// sim::Phys::spawn (+ the step's sub-step birth offset). +fn ps_spawn_engine(id: u32) -> Part { + let flat = S.u2.y != 0u; + let anim = S.u2.x; + let producer = S.f[1].xyz; + let radius = S.f[2].xyz; + let u = ps_rand_dir(id, 1u, flat); + var e = 1.0 / 3.0; + if (flat) { + e = 0.5; + } + let rr = powz(ps_h(id, 3u), e); + var p = producer + u * radius * rr; + if (flat) { + p.z = 0.0; + } + let ax = S.f[4].xyz; + let jitter = ps_rand_dir(id, 5u, flat); + var dir = jitter; + switch anim { + case 1u, 9u: { + dir = ps_norm3(ax + jitter * 0.08); + } + case 2u: { + let k = S.f[0].w; + dir = ps_norm3(ax + jitter * k * ps_h(id, 8u)); + } + case 5u: { + var z = jitter.z; + if (flat) { + z = 0.0; + } + dir = vec3(jitter.x, 0.0, z); + } + case 6u: { + dir = ps_norm3(vec3(jitter.x * 0.25, -1.0, jitter.z * 0.25)); + } + case 7u: { + dir = ps_norm3(vec3(1.0, jitter.y * 0.05, jitter.z * 0.05)); + } + default: {} + } + let sp = S.f[0].y * (0.5 + 0.5 * ps_h(id, 9u)); + var v = dir * sp; + if (flat) { + v.z = 0.0; + } + let life = S.f[0].x * (0.8 + 0.4 * ps_h(id, 10u)); + let dt = S.f[5].y; + let frac = ps_h(id, 12u) * dt; + return Part(p + v * frac, v, frac, life, ps_h(id, 11u)); +} + +// sim::Phys::step for one particle at step `j`. +fn ps_step_engine(q: Part, j: u32) -> Part { + let flat = S.u2.y != 0u; + let anim = S.u2.x; + let seed = S.u2.z; + let dt = S.f[5].y; + let t = f32(j) * dt; + let producer = S.f[1].xyz; + let gravity = S.f[3].xyz; + let extra = S.f[0].z; + var a = gravity; + switch anim { + case 4u: { + let dx = q.p.x - producer.x; + let dz = q.p.z - producer.z; + let k = 4.0 * extra; + a.x += -dz * k; + a.z += dx * k; + if (flat) { + let dy = q.p.y - producer.y; + a.x += -dy * k; + a.y += dx * k; + } + } + case 5u: { + let dx = q.p.x - producer.x; + let dy = q.p.y - producer.y; + let k = 6.0 * extra; + a.x += -dy * k - dx * 2.0; + a.y += dx * k - dy * 2.0; + } + case 6u: { + let n = value_noise(q.p.x * 8.0, q.p.y * 8.0, t * 3.0, seed) - 0.5; + a.x += n * 4.0 * extra; + a.y -= gravity.y * 1.5 + 0.3 * extra; + } + case 8u, 9u: { + let f = 6.0 * extra; + a.x += (value_noise(q.p.x * 5.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + a.y += (value_noise(q.p.x * 5.0 + 17.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + if (!flat) { + a.z += (value_noise(q.p.x * 5.0, q.p.y * 5.0 + 31.0, q.p.z * 5.0 + t, seed) - 0.5) * f; + } + } + default: {} + } + let damp = S.f[5].x; + var o = q; + o.v = (q.v + a * dt) * damp; + o.p = q.p + o.v * dt; + if (flat) { + o.p.z = 0.0; + o.v.z = 0.0; + } + o.age = q.age + dt; + return o; +} + +// sim3::Pg::spawn (+ the sub-step birth offset). +fn ps_spawn_cannon(id: u32) -> Part { + let pos = S.f[0].xy; + let angle = S.f[0].z; + let r = S.f[0].w; + let spread = S.f[1].x; + let vel = S.f[1].y; + let vel_spread = S.f[1].z; + let a = radians(angle + ps_hs(id, 1u) * spread * 0.5); + let d = vec2(sin(a), -cos(a)); + let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0); + var off: vec2; + if (r >= 0.0) { + off = vec2(ps_hs(id, 2u) * r, ps_hs(id, 4u) * r); + } else { + let aa = ps_h(id, 5u) * PS_TAU; + let rr = sqrt(ps_h(id, 6u)) * -r; + off = vec2(cos(aa) * rr, sin(aa) * rr); + } + let v = d * speed; + let dt = S.f[2].z; + let frac = ps_h(id, 9u) * dt; + let p = pos + off + v * frac; + return Part(vec3(p, 0.0), vec3(v, 0.0), 0.0, 0.0, 0.0); +} + +// sim3::Pg::step for one particle (gravity only); returns false when it leaves the bounds. +fn ps_step_cannon(q: ptr, id: u32) -> bool { + let dt = S.f[2].z; + let spread = S.f[1].w; + var g = 1.0; + if (spread > 0.0) { + g = 1.0 + (ps_h(id, 7u) * 2.0 - 1.0) * spread; + } + let a = S.f[2].xy * g; + let v = ((*q).v.xy + a * dt) * 1.0; + let p = (*q).p.xy + v * dt; + (*q).v = vec3(v, 0.0); + (*q).p = vec3(p, 0.0); + let b = S.f[3]; + return p.x > b.x && p.x < b.z && p.y > b.y && p.y < b.w; +} + +@compute @workgroup_size(64, 1) +fn particles(@builtin(global_invocation_id) gid: vec3) { + let id = gid.x; + if (id >= S.u.x) { + return; + } + let s0 = S.u.y; + let s1 = S.u.z; + let cannon = S.u.w == 1u; + let b = births[id]; + var q: Part; + var j: u32; + if (b < s0 && id < S.u2.w) { + let o = prev[id]; + if (o.m.y == 0.0) { + next[id] = o; + return; + } + q = Part(o.p.xyz, o.v.xyz, o.p.w, o.v.w, o.m.x); + j = s0; + } else { + if (cannon) { + q = ps_spawn_cannon(id); + } else { + q = ps_spawn_engine(id); + } + j = b + 1u; + } + var alive = true; + for (; j < s1; j++) { + if (cannon) { + if (!ps_step_cannon(&q, id)) { + alive = false; + break; + } + } else { + q = ps_step_engine(q, j); + if (q.age >= q.life) { + alive = false; + break; + } + } + } + next[id] = PState(vec4(q.p, q.age), vec4(q.v, q.life), vec4(q.rnd, select(0.0, 1.0, alive), 0.0, 0.0)); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index c6fd84f0..f795b58d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -26,7 +26,7 @@ use effectcraft_time::{FrameRate, Tick}; use crate::Gpu; -fn gpu() -> Option<&'static Gpu> { +pub(crate) fn gpu() -> Option<&'static Gpu> { static G: OnceLock> = OnceLock::new(); G.get_or_init(|| { let g = Gpu::headless(); @@ -40,9 +40,9 @@ fn gpu() -> Option<&'static Gpu> { /// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and /// fully transparent regions (different per item). -struct Pattern; +pub(crate) struct Pattern; -fn pattern(seed: u32, w: u32, h: u32) -> Image { +pub(crate) fn pattern(seed: u32, w: u32, h: u32) -> Image { let mut img = Image::new(w, h); let k = seed as f32 * 0.37; for y in 0..h { @@ -66,14 +66,14 @@ impl FootageSource for Pattern { } } -struct Scene { - p: Project, - cid: ItemId, - comp: Comp, +pub(crate) struct Scene { + pub(crate) p: Project, + pub(crate) cid: ItemId, + pub(crate) comp: Comp, } impl Scene { - fn new(depth: BitDepth) -> Scene { + pub(crate) fn new(depth: BitDepth) -> Scene { let mut p = Project::default(); p.settings.bit_depth = depth; let comp = Comp::new(97, 61, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); @@ -81,7 +81,7 @@ impl Scene { Scene { p, cid, comp } } - fn footage(&mut self, w: u32, h: u32) -> Layer { + pub(crate) fn footage(&mut self, w: u32, h: u32) -> Layer { let f = Footage { path: "pattern.png".into(), kind: FootageKind::Still, @@ -106,19 +106,19 @@ impl Scene { build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None) } - fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer { + pub(crate) fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer { let sid = self.p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 })); build::layer(&mut self.p, &self.comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None) } /// Add on top of the stack. - fn push(&mut self, l: Layer) -> effectcraft_project::LayerId { + pub(crate) fn push(&mut self, l: Layer) -> effectcraft_project::LayerId { let id = l.id; self.p.comp_mut(self.cid).unwrap().layers.insert(0, l); id } - fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) { + pub(crate) fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) { let spec = effectcraft_effects::find(id).unwrap(); let mut next = self.p.next_id; let size = effectcraft_render::source_size(&self.p, l); @@ -131,27 +131,27 @@ impl Scene { } } -fn set(l: &mut Layer, path: &str, v: Value) { +pub(crate) fn set(l: &mut Layer, path: &str, v: Value) { l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v; } -fn v3(x: f64, y: f64) -> Value { +pub(crate) fn v3(x: f64, y: f64) -> Value { Value::Vec3([x, y, 0.0]) } /// Differences of a GPU render against the CPU render. #[derive(Debug)] -struct Diff { - max: f32, +pub(crate) struct Diff { + pub(crate) max: f32, /// Pixels over the tolerance. - over: usize, - total: usize, - worst: (u32, u32, [f32; 4], [f32; 4]), + pub(crate) over: usize, + pub(crate) total: usize, + pub(crate) worst: (u32, u32, [f32; 4], [f32; 4]), /// 8/16 bpc render. - quantized: bool, + pub(crate) quantized: bool, } -fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { +pub(crate) fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { assert_eq!((cpu.width, cpu.height), (gpu.width, gpu.height), "size"); 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() { @@ -169,7 +169,7 @@ fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff { d } -fn tolerance(depth: BitDepth) -> f32 { +pub(crate) fn tolerance(depth: BitDepth) -> f32 { match depth { BitDepth::Bpc8 => 1.0 / 255.0 + 1e-6, _ => 1e-3, @@ -177,7 +177,7 @@ fn tolerance(depth: BitDepth) -> f32 { } /// Render on the CPU and the GPU; `Some(diff)` (None without an adapter). -fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { +pub(crate) fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { let g = gpu()?; let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t); let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts }); @@ -188,12 +188,12 @@ fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option { Some(d) } -fn opts() -> RenderOpts { +pub(crate) fn opts() -> RenderOpts { RenderOpts { motion_blur: true, ..Default::default() } } /// Assert agreement; `allow` = fraction of pixels allowed over the tolerance. -fn check(label: &str, d: Option, allow: f64) { +pub(crate) fn check(label: &str, d: Option, allow: f64) { let Some(d) = d else { return }; let frac = d.over as f64 / d.total as f64; // Quantised depths: rounding-boundary flips (see the module docs). @@ -379,7 +379,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() { /// The effect alone on a buffer: GPU kernels vs the CPU effect (32-bit float), including the /// buffer geometry (padding, offset). -fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { +pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { let Some(g) = gpu() else { return }; let spec = effectcraft_effects::find(id).unwrap(); let size = [70.0, 44.0]; @@ -401,7 +401,7 @@ fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) { } /// One footage layer with `fx` applied, composited over a background, at 8 and 32 bpc. -fn effect_case(id: &str, vals: &[(&str, Value)]) { +pub(crate) fn effect_case(id: &str, vals: &[(&str, Value)]) { effect_direct(id, vals, false); effect_direct(id, vals, true); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { @@ -417,11 +417,11 @@ fn effect_case(id: &str, vals: &[(&str, Value)]) { } } -fn n(v: f64) -> Value { +pub(crate) fn n(v: f64) -> Value { Value::Scalar(v) } -fn c(r: f64, g: f64, b: f64) -> Value { +pub(crate) fn c(r: f64, g: f64, b: f64) -> Value { Value::Color([r, g, b, 1.0]) } diff --git a/crates/gpu/src/tests_3d.rs b/crates/gpu/src/tests_3d.rs new file mode 100644 index 00000000..f10368d2 --- /dev/null +++ b/crates/gpu/src/tests_3d.rs @@ -0,0 +1,220 @@ +//! Classic 3D on the GPU vs the CPU compositor: intersecting planes, blend modes, lights, +//! ray-cast shadows, depth of field, motion blur, track mattes, collapsed precomps and +//! adjustment layers splitting a run. Same tolerances as `tests.rs`; pixels on the exact +//! intersection line of two planes may sort differently (f32 vs f64 depths), so intersecting +//! scenes allow 0.5 % of pixels over. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKind, MatteKind, TrackMatte}; +use effectcraft_render::{Backend, RenderOpts, Renderer}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set}; + +const W: u32 = 160; +const H: u32 = 100; + +fn scene(depth: BitDepth) -> Scene { + let mut s = Scene::new(depth); + let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + *s.p.comp_mut(s.cid).unwrap() = comp.clone(); + s.comp = comp; + s +} + +fn three_d(mut l: Layer, pos: [f64; 3], rot_y: f64) -> Layer { + l.switches.three_d = true; + set(&mut l, "transform/position", Value::Vec3(pos)); + set(&mut l, "transform/rotationY", Value::Scalar(rot_y)); + l +} + +fn camera(s: &mut Scene, pos: [f64; 3], dof: Option<(f64, f64)>) { + let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (W, H), None); + set(&mut cam, "cameraOptions/zoom", Value::Scalar(W as f64 * 50.0 / 36.0)); + set(&mut cam, "transform/position", Value::Vec3(pos)); + set(&mut cam, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0])); + if let Some((focus, aperture)) = dof { + set(&mut cam, "cameraOptions/dof", Value::Bool(true)); + set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus)); + set(&mut cam, "cameraOptions/aperture", Value::Scalar(aperture)); + } + s.push(cam); +} + +fn light(s: &mut Scene, kind: LightKind, pos: [f64; 3], vals: &[(&str, Value)]) { + let mut l = build::layer(&mut s.p, &s.comp, "Light", LayerSource::Light { kind }, (W, H), None); + if kind != LightKind::Ambient { + set(&mut l, "transform/position", Value::Vec3(pos)); + } + if l.props.prop_mut("transform/poi").is_some() { + set(&mut l, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0])); + } + for (k, v) in vals { + set(&mut l, &format!("lightOptions/{k}"), v.clone()); + } + s.push(l); +} + +/// Background, a far plane, and two planes crossing each other (one in `mode`). +fn crossing(depth: BitDepth, mode: BlendMode) -> Scene { + let mut s = scene(depth); + let bg = s.solid([0.1, 0.12, 0.2], W, H); + s.push(bg); + let far = three_d(s.footage(150, 90), [80.0, 50.0, 120.0], 0.0); + s.push(far); + let a = three_d(s.footage(90, 70), [80.0, 50.0, 0.0], 35.0); + s.push(a); + let mut b = three_d(s.solid([0.9, 0.5, 0.2], 90, 70), [80.0, 52.0, 0.0], -35.0); + b.blend_mode = mode; + set(&mut b, "transform/opacity", Value::Scalar(80.0)); + s.push(b); + s +} + +#[test] +fn intersecting_planes_and_blend_modes() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Multiply, BlendMode::Add, BlendMode::Dissolve] { + let mut s = crossing(depth, mode); + check(&format!("crossing {mode:?} {depth:?} (default camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + camera(&mut s, [30.0, 20.0, -260.0], None); + check(&format!("crossing {mode:?} {depth:?} (camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } + } +} + +#[test] +fn lights_specular_transmission_and_shadows() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + // Every layer casts shadows; the crossing pair transmits light. + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.switches.three_d { + set(l, "materialOptions/castsShadows", Value::Enum(1)); + set(l, "materialOptions/specularIntensity", Value::Scalar(60.0)); + set(l, "materialOptions/specularShininess", Value::Scalar(30.0)); + set(l, "materialOptions/lightTransmission", Value::Scalar(40.0)); + } + } + camera(&mut s, [-20.0, -10.0, -250.0], None); + light(&mut s, LightKind::Ambient, [0.0, 0.0, 0.0], &[("intensity", n(25.0))]); + light( + &mut s, + LightKind::Spot, + [20.0, -60.0, -150.0], + &[("castsShadows", Value::Bool(true)), ("shadowDarkness", n(80.0)), ("coneAngle", n(70.0)), ("coneFeather", n(40.0)), ("intensity", n(120.0))], + ); + light( + &mut s, + LightKind::Point, + [150.0, 20.0, -80.0], + &[ + ("castsShadows", Value::Bool(true)), + ("shadowDiffusion", n(6.0)), + ("falloff", Value::Enum(1)), + ("radius", n(120.0)), + ("falloffDistance", n(200.0)), + ], + ); + light(&mut s, LightKind::Parallel, [-100.0, -100.0, -100.0], &[("castsShadows", Value::Bool(true)), ("color", Value::Color([0.5, 0.7, 1.0, 1.0]))]); + check(&format!("lights + shadows {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // Accepts Shadows: Only, Accepts Lights off, Casts Shadows: Only. + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + match l.name.as_str() { + "Pattern" => set(l, "materialOptions/acceptsShadows", Value::Enum(2)), + "Solid" if l.switches.three_d => set(l, "materialOptions/acceptsLights", Value::Bool(false)), + _ => {} + } + } + check(&format!("shadow-only materials {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + +#[test] +fn depth_of_field_and_motion_blur() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [80.0, 50.0, -220.0], Some((220.0, 30.0))); + check(&format!("dof {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // Animated plane with motion blur (sub-sampled geometry, equal weights). + let mut s = scene(depth); + let mut l = three_d(s.footage(60, 40), [40.0, 50.0, 0.0], 20.0); + l.switches.motion_blur = true; + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([40.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([140.0, 40.0, -60.0]))]; + s.push(l); + s.p.comp_mut(s.cid).unwrap().enable_motion_blur = true; + light(&mut s, LightKind::Point, [80.0, 0.0, -100.0], &[]); + check(&format!("3d motion blur {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.5)), 0.005); + } +} + +#[test] +fn mattes_preserve_transparency_precomps_and_adjustment_splits() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = crossing(depth, BlendMode::Normal); + // A 2D luma matte for the top plane, preserve transparency on the far plane. + let m = s.solid([0.7, 0.7, 0.7], 70, 50); + let mid = s.push(m); + let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; + layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma }); + if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") { + far.preserve_transparency = true; + } + // Move the matte below the bottom so the 3D run stays one run. + let ml = layers.remove(0); + layers.push(ml); + layers.last_mut().unwrap().switches.video = false; + check(&format!("matte + preserve {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + + // An adjustment layer in the middle of the run splits it. + let mut s = crossing(depth, BlendMode::Normal); + let mut adj = s.solid([1.0, 1.0, 1.0], W, H); + adj.switches.adjustment = true; + adj.switches.three_d = true; + s.effect(&mut adj, "ec.channel.invert", &[]); + s.p.comp_mut(s.cid).unwrap().layers.insert(1, adj); + check(&format!("adjustment split {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + + // A collapsed 3D precomp: its layers join the parent's 3D space. + let mut s = crossing(depth, BlendMode::Normal); + let inner = Comp::new(80, 60, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into())); + let mut a = s.footage(50, 40); + a.switches.three_d = true; + set(&mut a, "transform/rotationX", Value::Scalar(40.0)); + let b = s.solid([0.2, 0.8, 0.4], 30, 20); + s.p.comp_mut(inner_id).unwrap().layers = vec![b, a]; + let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None); + pre.switches.collapse = true; + pre.switches.three_d = true; + set(&mut pre, "transform/position", Value::Vec3([70.0, 45.0, -10.0])); + s.push(pre); + check(&format!("collapsed 3d precomp {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } +} + +#[test] +fn the_gpu_draws_classic_3d_runs_itself() { + let Some(g) = gpu() else { return }; + let s = crossing(BitDepth::Bpc32, BlendMode::Normal); + let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let vis = r.visible_layers(&ctx); + let run: Vec<_> = vis.iter().copied().filter(|l| l.is_3d()).collect(); + let prep = r.prepare_3d_run(&ctx, &run, (W, H)).expect("a classic run is prepared for the GPU"); + assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3); + let mut e = crate::context::Enc::new(g.context()); + let canvas = e.image(W, H); + let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run"); + let img = e.download(&out).unwrap(); + let mut cpu = effectcraft_raster::Image::new(W, H); + r.draw_3d_run(&ctx, &run, &mut cpu); + let covered = cpu.data.iter().filter(|p| p[3] > 0.5).count(); + assert!(covered > (W * H / 3) as usize, "the planes cover the frame ({covered} px)"); + let d = crate::tests::diff(&cpu, &img, 1e-3); + assert!(d.over * 200 <= d.total, "{d:?}"); +} diff --git a/crates/gpu/src/tests_adjust.rs b/crates/gpu/src/tests_adjust.rs new file mode 100644 index 00000000..ae7f88da --- /dev/null +++ b/crates/gpu/src/tests_adjust.rs @@ -0,0 +1,105 @@ +//! Adjustment layers on the GPU vs the CPU: GPU-only stacks (resident on the device), mixed +//! stacks (CPU effects in between read back and upload), masks, opacity, colour management. + +use effectcraft_keyframe::{ShapePath, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, ColorSpace, MaskMode}; +use effectcraft_render::{Backend, FxStep, FxTarget, RenderOpts, Renderer}; +use effectcraft_time::Tick; + +use crate::tests::{Pattern, Scene, c, check, compare_at, gpu, n, opts, set}; + +fn adjustment_scene(depth: BitDepth, stack: &[(&str, Vec<(&str, Value)>)], masked: bool) -> Scene { + let mut s = Scene::new(depth); + let bg = s.footage(97, 61); + s.push(bg); + let mut l = s.footage(60, 40); + set(&mut l, "transform/rotation", Value::Scalar(15.0)); + s.push(l); + let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50); + adj.switches.adjustment = true; + set(&mut adj, "transform/opacity", Value::Scalar(75.0)); + set(&mut adj, "transform/rotation", Value::Scalar(-6.0)); + for (id, vals) in stack { + s.effect(&mut adj, id, vals); + } + if masked { + let mut next = s.p.next_id; + let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 25.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]); + s.p.next_id = next; + adj.props.sub_mut("masks").unwrap().children.push(m.into()); + } + s.push(adj); + let top = s.solid([0.2, 0.6, 0.9], 20, 20); + s.push(top); + s +} + +fn gpu_stack() -> Vec<(&'static str, Vec<(&'static str, Value)>)> { + vec![ + ("ec.blur.gaussian", vec![("blurriness", n(6.0))]), + ("ec.color.levels", vec![("inBlack", n(0.1)), ("gamma", n(1.4))]), + ("ec.color.tint", vec![("black", c(0.1, 0.0, 0.2)), ("amount", n(60.0))]), + ] +} + +#[test] +fn gpu_adjustment_chains_match_the_cpu() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + for masked in [false, true] { + let s = adjustment_scene(depth, &gpu_stack(), masked); + check(&format!("gpu adjustment {depth:?} masked {masked}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("gpu adjustment {depth:?} masked {masked} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn mixed_adjustment_chains_fall_back_per_effect() { + let mut stack = gpu_stack(); + stack.insert(1, ("ec.stylize.posterize", 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); + check(&format!("mixed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } + // Colour-managed: effects run in the working space, compositing in linear light. + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = adjustment_scene(depth, &stack, false); + s.p.settings.working_space = Some(ColorSpace::Rec709); + s.p.settings.blend_linear = true; + check(&format!("colour-managed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0); + } +} + +/// Counts where each run of the stack goes. +#[derive(Default)] +struct Probe { + gpu: usize, + cpu: usize, +} + +impl FxTarget for Probe { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + self.gpu += steps.len(); + true + } + fn cpu(&mut self, _f: &mut dyn FnMut(effectcraft_effects::Buf) -> effectcraft_effects::Buf) { + self.cpu += 1; + } +} + +#[test] +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.posterize", 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); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let adj = ctx.comp.layers.iter().find(|l| l.switches.adjustment).unwrap(); + let mut p = Probe::default(); + r.run_effects_on(&ctx, adj, true, &mut p); + assert_eq!((p.gpu, p.cpu), (3, 1)); +} diff --git a/crates/gpu/src/tests_fx_color.rs b/crates/gpu/src/tests_fx_color.rs new file mode 100644 index 00000000..8fb455b8 --- /dev/null +++ b/crates/gpu/src/tests_fx_color.rs @@ -0,0 +1,163 @@ +//! GPU colour-family effects 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) +} + +#[test] +fn color_balance_vibrance_tritone_mixer() { + effect_case("ec.color.colorbalance", &[("shadowRed", n(40.0)), ("midGreen", n(-30.0)), ("hiBlue", n(60.0))]); + effect_case("ec.color.colorbalance", &[("shadowBlue", n(-50.0)), ("midRed", n(70.0)), ("hiGreen", n(25.0)), ("preserveLuminosity", on())]); + effect_case("ec.color.vibrance", &[("vibrance", n(60.0)), ("saturation", n(-20.0))]); + effect_case("ec.color.vibrance", &[("vibrance", n(-40.0)), ("saturation", n(35.0))]); + effect_case("ec.color.tritone", &[("midtones", c(0.6, 0.3, 0.5)), ("shadows", c(0.0, 0.1, 0.3)), ("blend", n(30.0))]); + effect_case("ec.color.tritone", &[("highlights", c(1.0, 0.9, 0.6))]); + effect_case("ec.color.channelmixer", &[("rr", n(80.0)), ("rg", n(30.0)), ("gb", n(-40.0)), ("bc", n(10.0)), ("br", n(50.0))]); + effect_case("ec.color.channelmixer", &[("rr", n(40.0)), ("rg", n(40.0)), ("rb", n(20.0)), ("rc", n(-5.0)), ("monochrome", on())]); +} + +#[test] +fn black_white_colorama_selective_color() { + effect_case("ec.color.blackwhite", &[]); + effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]); + effect_case("ec.color.colorama", &[]); + effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]); + effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]); + effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]); + effect_case( + "ec.color.selectivecolor", + &[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))], + ); +} + +#[test] +fn lumetri() { + effect_case( + "ec.color.lumetri", + &[ + ("basicCorrection/whiteBalance/temperature", n(30.0)), + ("basicCorrection/whiteBalance/tint", n(-20.0)), + ("basicCorrection/tone/exposure", n(0.5)), + ("basicCorrection/tone/contrast", n(25.0)), + ("basicCorrection/tone/highlights", n(-40.0)), + ("basicCorrection/tone/shadows", n(30.0)), + ("basicCorrection/tone/whites", n(10.0)), + ("basicCorrection/tone/blacks", n(-15.0)), + ("basicCorrection/saturation", n(130.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("creative/lookIntensity", n(80.0)), + ("creative/adjustments/fadedFilm", n(30.0)), + ("creative/adjustments/vibrance", n(40.0)), + ("creative/adjustments/creativeSaturation", n(70.0)), + ("creative/adjustments/shadowTint", c(0.3, 0.5, 0.7)), + ("creative/adjustments/highlightTint", c(0.7, 0.55, 0.4)), + ("creative/adjustments/tintBalance", n(-30.0)), + ("curves/curveMasterMidtones", n(30.0)), + ("curves/curveRedShadows", n(-20.0)), + ("curves/curveBlueHighlights", n(40.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("colorWheels/shadowsWheel", c(0.4, 0.5, 0.6)), + ("colorWheels/highlightsWheel", c(0.6, 0.5, 0.45)), + ("vignette/vignetteAmount", n(-2.0)), + ("vignette/vignetteMidpoint", n(30.0)), + ("vignette/vignetteRoundness", n(40.0)), + ("creative/adjustments/sharpen", n(50.0)), + ], + ); + effect_case( + "ec.color.lumetri", + &[ + ("vignette/vignetteAmount", n(1.5)), + ("vignette/vignetteRoundness", n(-60.0)), + ("vignette/vignetteFeather", n(20.0)), + ("creative/adjustments/sharpen", n(-40.0)), + ], + ); +} + +#[test] +fn linear_color_key() { + effect_case("ec.key.linearcolor", &[("keyColor", c(0.6, 0.5, 0.4)), ("tolerance", n(15.0)), ("softness", n(20.0))]); + effect_case("ec.key.linearcolor", &[("keyColor", c(0.2, 0.8, 0.3)), ("matchColors", e(1)), ("keyOperation", e(1)), ("view", e(2))]); + effect_case("ec.key.linearcolor", &[("keyColor", c(0.7, 0.3, 0.6)), ("matchColors", e(2)), ("tolerance", n(5.0)), ("softness", n(30.0))]); +} + +#[test] +fn key_light() { + let screen = ("screenColour", c(0.3, 0.75, 0.35)); + effect_case("ec.keying.keylight", std::slice::from_ref(&screen)); + effect_case( + "ec.keying.keylight", + &[ + // A low gain against a pure screen keeps the raw matte away from 0 (the screen + // removal divides by it; near 0 it amplifies the blurs' float rounding). + ("screenColour", c(0.0, 1.0, 0.0)), + ("screenGain", n(60.0)), + ("screenBalance", n(30.0)), + ("screenPreblur", n(3.0)), + ("screenMatte/clipBlack", n(10.0)), + ("screenMatte/clipWhite", n(80.0)), + ("screenMatte/clipRollback", n(2.5)), + ("screenMatte/screenSoftness", n(3.0)), + ("screenMatte/replaceMethod", e(3)), + ("screenMatte/replaceColour", c(0.6, 0.4, 0.3)), + ("unpremultiplyResult", Value::Bool(false)), + ], + ); + effect_case( + "ec.keying.keylight", + &[ + ("screenColour", c(0.2, 0.4, 0.8)), + ("lockBiasesTogether", Value::Bool(false)), + ("alphaBias", c(0.5, 0.45, 0.6)), + ("despillBias", c(0.55, 0.5, 0.45)), + ("screenMatte/clipWhite", n(70.0)), + ("screenMatte/screenDespotBlack", n(2.0)), + ("screenMatte/screenDespotWhite", n(1.5)), + ("screenMatte/screenShrinkGrow", n(-1.5)), + ("screenMatte/replaceMethod", e(2)), + ("foregroundColourCorrection/enableColourCorrection", on()), + ("foregroundColourCorrection/saturation", n(120.0)), + ("foregroundColourCorrection/contrast", n(15.0)), + ("edgeColourCorrection/enableEdgeColourCorrection", on()), + ("edgeColourCorrection/edgeSoftness", n(2.0)), + ("edgeColourCorrection/edgeHardness", n(30.0)), + ("edgeColourCorrection/edgeBrightness", n(-20.0)), + ("sourceCrops/cropLeft", n(13.0)), + ("sourceCrops/cropBottom", n(7.0)), + ("insideMask/sourceAlpha", e(1)), + ], + ); + // Soft Colour without Screen Softness (after the blur, the screen-removed foreground of + // nearly transparent pixels, divided by a raw matte near 1e-4, amplifies float rounding). + effect_case( + "ec.keying.keylight", + &[ + screen.clone(), + ("screenMatte/clipBlack", n(15.0)), + ("screenMatte/clipRollback", n(1.5)), + ("screenMatte/replaceColour", c(0.6, 0.4, 0.3)), + ("insideMask/sourceAlpha", e(0)), + ], + ); + for view in [1, 2, 4, 7, 9] { + effect_case("ec.keying.keylight", &[screen.clone(), ("view", e(view)), ("screenMatte/screenShrinkGrow", n(1.0)), ("screenMatte/replaceMethod", e(1))]); + } +} diff --git a/crates/gpu/src/tests_fx_distort.rs b/crates/gpu/src/tests_fx_distort.rs new file mode 100644 index 00000000..9732f99c --- /dev/null +++ b/crates/gpu/src/tests_fx_distort.rs @@ -0,0 +1,119 @@ +//! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`. + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn on() -> Value { + Value::Bool(true) +} + +#[test] +fn twirl_bulge_ripple_wave_warp() { + effect_case("ec.distort.twirl", &[("angle", n(120.0)), ("radius", n(40.0))]); + effect_case("ec.distort.twirl", &[("angle", n(-400.0)), ("radius", n(70.0)), ("center", Value::Vec2([25.0, 18.0]))]); + effect_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]); + effect_case("ec.distort.bulge", &[("height", n(-2.5)), ("taperRadius", n(60.0)), ("pinAllEdges", on()), ("center", Value::Vec2([30.0, 25.0]))]); + effect_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]); + effect_case("ec.distort.ripple", &[("conversion", e(1)), ("phase", n(70.0)), ("speed", n(3.0)), ("center", Value::Vec2([20.0, 30.0]))]); + effect_case("ec.distort.wavewarp", &[("height", n(6.0)), ("width", n(25.0)), ("direction", n(30.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]); + effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]); +} + +#[test] +fn turbulent_displace_and_cc_lens() { + effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]); + effect_case( + "ec.distort.turbulentdisplace", + &[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())], + ); + effect_case( + "ec.distort.turbulentdisplace", + &[ + ("displacement", e(5)), + ("evolutionOptions/cycleEvolution", on()), + ("evolutionOptions/cycle", n(2.0)), + ("evolution", n(500.0)), + ("evolutionOptions/randomSeed", n(7.0)), + ("pinning", e(0)), + ("amount", n(-30.0)), + ], + ); + effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]); + effect_case("ec.distort.cclens", &[("size", n(70.0))]); + effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]); + effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]); +} + +#[test] +fn mesh_warp() { + // 2 rows × 3 columns: 12 vertices. + let mesh = "0,0 0,0 0,0 0,0 0,0 6,-4 -5,3 0,0 0,0 0,0 0,0 0,0"; + effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(3.0)), ("quality", n(3.0)), ("mesh", Value::Str(mesh.into()))]); + let mesh = "-4,-3 2,0 8,5 0,-2 9,9 1,0 -3,4 2,2 4,-6"; + effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(2.0)), ("quality", n(5.0)), ("mesh", Value::Str(mesh.into()))]); +} + +#[test] +fn mosaic_find_edges_emboss() { + effect_case("ec.stylize.mosaic", &[]); + effect_case("ec.stylize.mosaic", &[("horizontal", n(7.0)), ("vertical", n(3.0))]); + effect_case("ec.stylize.mosaic", &[("horizontal", n(13.0)), ("vertical", n(9.0)), ("sharpColors", on())]); + effect_case("ec.stylize.findedges", &[]); + effect_case("ec.stylize.findedges", &[("invert", on()), ("blend", n(30.0))]); + effect_case("ec.stylize.emboss", &[]); + effect_case("ec.stylize.emboss", &[("direction", n(160.0)), ("relief", n(4.0)), ("contrast", n(250.0)), ("blend", n(20.0))]); +} + +#[test] +fn displacement_map_self() { + effect_case("ec.distort.displacementmap", &[("maxHorizontal", n(8.0)), ("maxVertical", n(-5.0))]); + effect_case("ec.distort.displacementmap", &[("useForHorizontal", e(4)), ("useForVertical", e(3)), ("wrapPixelsAround", on())]); + effect_case( + "ec.distort.displacementmap", + &[("useForHorizontal", e(10)), ("useForVertical", e(6)), ("maxVertical", n(12.0)), ("expandOutput", Value::Bool(false))], + ); +} + +/// Displacement Map reading another layer (Center / Stretch / Tile), composited. +#[test] +fn displacement_map_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (behavior, wrap) in [(0, false), (1, false), (2, true), (2, false)] { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let mut map = s.footage(50, 36); + map.switches.video = false; + let mid = s.push(map); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.distort.displacementmap", + &[ + ("displacementMapLayer", Value::Layer(Some(mid.0))), + ("useForHorizontal", e(0)), + ("useForVertical", e(4)), + ("maxHorizontal", n(9.0)), + ("maxVertical", n(6.0)), + ("displacementMapBehavior", e(behavior)), + ("wrapPixelsAround", Value::Bool(wrap)), + ], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("displacement map {behavior} {wrap} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} diff --git a/crates/gpu/src/tests_fx_generate.rs b/crates/gpu/src/tests_fx_generate.rs new file mode 100644 index 00000000..e15abb37 --- /dev/null +++ b/crates/gpu/src/tests_fx_generate.rs @@ -0,0 +1,200 @@ +//! GPU vs CPU for the blur, transition and generate family (`fx_generate`). + +use effectcraft_keyframe::Value; +use effectcraft_project::BitDepth; +use effectcraft_render::RenderOpts; +use effectcraft_time::Tick; + +use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set}; + +fn e(v: u32) -> Value { + Value::Enum(v) +} + +fn b(v: bool) -> Value { + Value::Bool(v) +} + +fn pt(x: f64, y: f64) -> Value { + Value::Vec2([x, y]) +} + +#[test] +fn radial_blur() { + effect_case("ec.blur.radial", &[("amount", n(12.0))]); + effect_case("ec.blur.radial", &[("amount", n(30.0)), ("type", e(1)), ("center", pt(20.3, 14.7))]); + effect_case("ec.blur.radial", &[("amount", n(-45.0)), ("center", pt(50.0, 30.0))]); +} + +#[test] +fn cc_radial_fast_blur() { + effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]); + effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]); +} + +#[test] +fn camera_lens_blur() { + effect_case("ec.blur.cameralens", &[("blurRadius", n(5.0))]); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(7.5)), + ("irisProperties/irisShape", e(2)), + ("irisProperties/irisRotation", n(20.0)), + ("irisProperties/irisAspectRatio", n(1.6)), + ("repeatEdge", b(true)), + ], + ); + effect_case( + "ec.blur.cameralens", + &[ + ("blurRadius", n(4.0)), + ("irisProperties/irisShape", e(8)), + ("highlight/specularBrightness", n(60.0)), + ("highlight/specularThreshold", n(150.0)), + ("highlight/highlightSaturation", n(40.0)), + ("useLinear", b(true)), + ], + ); + // Large iris: per-row prefix sums. + effect_case("ec.blur.cameralens", &[("blurRadius", n(14.0)), ("irisProperties/irisRoundness", n(60.0))]); + effect_case("ec.blur.cameralens", &[("blurRadius", n(12.0)), ("repeatEdge", b(true)), ("useLinear", b(true))]); +} + +#[test] +fn wipes() { + effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]); + effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]); + effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]); + effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]); + effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]); + effect_case("ec.transition.radialwipe", &[("completion", n(30.0)), ("startAngle", n(25.0)), ("feather", n(10.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]); + effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]); + effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]); + effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]); +} + +/// Gradient Wipe driven by another layer (fetched through the effect host, placed on the CPU). +#[test] +fn gradient_wipe_with_a_gradient_layer() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for placement in 0..3 { + let mut s = Scene::new(depth); + let bg = s.solid([0.15, 0.1, 0.2], 97, 61); + s.push(bg); + let grad = s.footage(40, 30); + let gid = s.push(grad); + let mut l = s.footage(70, 44); + s.effect( + &mut l, + "ec.transition.gradientwipe", + &[("completion", n(45.0)), ("softness", n(20.0)), ("gradientLayer", Value::Layer(Some(gid.0))), ("gradientPlacement", e(placement))], + ); + set(&mut l, "transform/rotation", Value::Scalar(8.0)); + s.push(l); + let label = format!("gradient wipe layer {placement} {depth:?}"); + check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0); + check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0); + } + } +} + +#[test] +fn generators() { + effect_case("ec.generate.cellpattern", &[("size", n(14.0))]); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(2)), ("size", n(11.0)), ("evolution", n(130.0)), ("overflow", e(1)), ("contrast", n(150.0))]); + effect_case( + "ec.generate.cellpattern", + &[ + ("cellPattern", e(11)), + ("size", n(9.0)), + ("tilingOptions/enableTiling", b(true)), + ("tilingOptions/cellsHorizontal", n(4.0)), + ("tilingOptions/cellsVertical", n(3.0)), + ("overflow", e(2)), + ("contrast", n(250.0)), + ("invert", b(true)), + ("evolutionOptions/randomSeed", n(17.0)), + ], + ); + effect_case("ec.generate.cellpattern", &[("cellPattern", e(5)), ("size", n(16.0)), ("disperse", n(0.6)), ("offset", pt(3.3, -7.1))]); + effect_case("ec.generate.checkerboard", &[("width", n(9.0))]); + effect_case( + "ec.generate.checkerboard", + &[ + ("sizeFrom", e(2)), + ("width", n(13.0)), + ("height", n(7.0)), + ("feather/featherWidth", n(4.0)), + ("anchor", pt(5.3, 2.2)), + ("blendingMode", e(5)), + ("opacity", n(70.0)), + ], + ); + effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]); + effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]); + effect_case( + "ec.generate.grid", + &[ + ("sizeFrom", e(2)), + ("width", n(15.0)), + ("height", n(9.0)), + ("border", n(2.5)), + ("feather/featherWidth", n(3.0)), + ("feather/featherHeight", n(2.0)), + ("invertGrid", b(true)), + ("blendingMode", e(4)), + ], + ); + effect_case( + "ec.generate.grid", + &[("sizeFrom", e(0)), ("corner", pt(11.0, 8.0)), ("anchor", pt(2.5, 3.5)), ("blendingMode", e(14)), ("color", c(0.2, 0.8, 0.4))], + ); + effect_case("ec.generate.fourcolor", &[]); + effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]); + effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]); +} + +#[test] +fn noise_and_grain() { + effect_case("ec.noise.noise", &[("amount", n(30.0))]); + effect_case("ec.noise.noise", &[("amount", n(50.0)), ("color", b(false))]); + effect_case("ec.noise.noise", &[("amount", n(40.0)), ("clip", b(false))]); + effect_case("ec.noise.addgrain", &[("tweaking/intensity", n(2.0))]); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(3.0)), + ("tweaking/size", n(2.5)), + ("tweaking/softness", n(1.5)), + ("tweaking/aspectRatio", n(1.4)), + ("tweaking/channelSize/redSize", n(0.6)), + ("color/saturation", n(0.5)), + ("color/tintAmount", n(0.4)), + ("color/tintColor", c(1.0, 0.6, 0.2)), + ("application/blendingMode", e(4)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[ + ("tweaking/intensity", n(1.5)), + ("color/monochromatic", b(true)), + ("application/blendingMode", e(3)), + ("application/shadows", n(0.3)), + ("application/highlights", n(1.8)), + ("application/midpoint", n(0.35)), + ("animation/randomSeed", n(42.0)), + ("animation/animationSpeed", n(2.0)), + ], + ); + effect_case( + "ec.noise.addgrain", + &[("tweaking/intensity", n(2.0)), ("application/blendingMode", e(1)), ("tweaking/channelIntensities/blueIntensity", n(2.5))], + ); +} diff --git a/crates/gpu/src/tests_particles.rs b/crates/gpu/src/tests_particles.rs new file mode 100644 index 00000000..e3ef97a8 --- /dev/null +++ b/crates/gpu/src/tests_particles.rs @@ -0,0 +1,155 @@ +//! GPU particles vs the CPU simulation (the oracle): CC Particle World, CC Particle Systems II +//! and Particle Playground's cannon at fixed seeds must give the same live particles (same +//! ids) at positions within tolerance, from scratch and when advancing / seeking from GPU +//! checkpoints. + +use effectcraft_effects::psim::{ParticleSim, SimParticle}; +use effectcraft_effects::{EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; +use effectcraft_keyframe::Value; + +use crate::Gpu; +use crate::tests::{gpu, n}; + +struct Host(&'static Gpu); + +impl EffectHost for Host { + fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option { + None + } + fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option> { + None + } + fn particles(&self) -> Option<&dyn ParticleSim> { + Some(self.0) + } +} + +fn params(id: &str, vals: &[(&str, Value)]) -> Params { + let spec = effectcraft_effects::find(id).unwrap(); + let size = [320.0, 240.0]; + let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() }; + for (k, v) in vals { + assert!(p.values.contains_key(*k), "{id}: no {k}"); + p.values.insert(k.to_string(), v.clone()); + } + p +} + +/// (CPU, GPU) particles of effect `id` at layer time `t`. +fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec, Vec) { + let ctx = |env| EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env }; + let run = + |c: &EffectCtx| if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) }; + let cpu = run(&ctx(EffectEnv::default())).expect("cpu state"); + let gpu = run(&ctx(EffectEnv { host: Some(host), ..Default::default() })).expect("gpu state"); + (cpu, gpu) +} + +/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol` +/// (world units) for 99 % of them and on average within `tol / 10`. +fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) { + assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len()); + let gi: std::collections::HashMap = gpu.iter().map(|q| (q.id, q)).collect(); + let mut missing = 0; + let mut errs = vec![]; + for q in cpu { + match gi.get(&q.id) { + Some(g) => errs.push((0..3).map(|k| (q.p[k] - g.p[k]).abs()).fold(0.0f32, f32::max)), + None => missing += 1, + } + } + let extra = gpu.len() + missing - cpu.len(); + assert!(missing + extra <= id_slack, "{label}: {missing} CPU particles missing on the GPU, {extra} extra (of {})", cpu.len()); + errs.sort_by(f32::total_cmp); + let mean = errs.iter().sum::() / errs.len() as f32; + let p99 = errs[(errs.len() * 99 / 100).min(errs.len() - 1)]; + eprintln!("{label}: {} particles, mean err {mean:.2e}, p99 {p99:.2e}, max {:.2e}", cpu.len(), errs.last().unwrap()); + assert!(p99 <= tol && mean <= tol / 10.0, "{label}: positions differ (mean {mean}, p99 {p99})"); +} + +#[test] +fn particle_world_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for anim in [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let p = params( + "ec.sim.ccparticleworld", + &[ + ("physics/animation", Value::Enum(anim)), + ("birthRate", n(2.0)), + ("longevity", n(1.2)), + ("physics/extra", n(0.6)), + ("physics/resistance", n(0.5)), + ("extras/randomSeed", n(7.0)), + ], + ); + let (cpu, gpu) = states("ec.sim.ccparticleworld", &p, 1.5, &host); + // Fractal / fire animations sample value noise along the path: rounding differences can + // grow, so they get a looser bound. + let tol = if matches!(anim, 6 | 8 | 9) { 1e-3 } else { 1e-4 }; + agree(&format!("particle world anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1); + } +} + +#[test] +fn particle_systems_ii_matches_the_cpu() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for anim in [0u32, 1, 2, 4, 5, 8] { + let p = params( + "ec.sim.ccparticlesystems2", + &[("physics/animation", Value::Enum(anim)), ("birthRate", n(3.0)), ("longevity", n(1.5)), ("physics/gravity", n(0.8)), ("randomSeed", n(3.0))], + ); + let (cpu, gpu) = states("ec.sim.ccparticlesystems2", &p, 2.0, &host); + let tol = if anim == 8 { 1e-3 } else { 1e-4 }; + agree(&format!("particle systems ii anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1); + } +} + +#[test] +fn playground_cannon_matches_the_cpu_and_checkpoints_reuse() { + let Some(g) = gpu() else { return }; + let host = Host(g); + for (radius, spread) in [(4.0, 0.0), (-10.0, 50.0)] { + let p = params( + "ec.sim.particleplayground", + &[ + ("grid/particlesAcross", n(0.0)), + ("cannon/barrelRadius", n(radius)), + ("cannon/particlesPerSecond", n(90.0)), + ("cannon/directionRandomSpread", n(30.0)), + ("cannon/velocityRandomSpread", n(40.0)), + ("gravity/gravityForceRandomSpread", n(spread)), + ], + ); + // Advance through checkpoints (0.5 → 1.0 → 2.0), seek back (1.5), compare each. + for t in [0.5, 1.0, 2.0, 1.5] { + let (cpu, gpu) = states("ec.sim.particleplayground", &p, t, &host); + // Positions are layer pixels here. + agree(&format!("cannon r {radius} spread {spread} t {t}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1); + } + } +} + +/// Through the renderer: the GPU backend's effect host hands the simulation to the GPU, and +/// the composited frames agree with the CPU render. +#[test] +fn particle_effects_render_like_the_cpu() { + use effectcraft_project::BitDepth; + use effectcraft_time::Tick; + + use crate::tests::{Scene, check, compare_at, opts}; + for (id, vals) in [ + ("ec.sim.ccparticleworld", vec![("birthRate", n(1.0)), ("physics/animation", Value::Enum(4))]), + ("ec.sim.ccparticlesystems2", vec![("birthRate", n(2.0))]), + ("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(60.0))]), + ] { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + let mut s = Scene::new(depth); + let mut l = s.solid([0.1, 0.1, 0.15], 97, 61); + s.effect(&mut l, id, &vals); + s.push(l); + check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(1.0)), 0.002); + } + } +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index 6051b4aa..edef7ef1 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -1,8 +1,9 @@ //! The per-frame compositing walk on the GPU: the same steps as `Renderer::draw_comp` (layer //! order, 3D runs, collapsed precomps, track mattes, Preserve Transparency, layer styles, motion //! blur, bit-depth quantisation, colour conversions), with layer pixels taken from the CPU -//! renderer and its layer cache. Steps without a GPU implementation (3D runs, adjustment and -//! wireframe layers) read the canvas back, run on the CPU and upload the result. +//! renderer and its layer cache. Classic 3D runs and adjustment layers run on the GPU; steps +//! without a GPU implementation (Advanced 3D runs, wireframes, anything a kernel declines) read +//! the canvas back, run on the CPU and upload the result. use std::sync::Arc; @@ -77,7 +78,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut while j < visible.len() && visible[j].is_3d() { j += 1; } - on_cpu(e, canvas, |img| r.draw_3d_run(ctx, &visible[i..j], img))?; + draw_3d(e, r, ctx, &visible[i..j], canvas)?; i = j; } else { match r.collapsed(ctx, visible[i]) { @@ -94,6 +95,25 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut Some(()) } +/// A run of consecutive 3D layers: Classic 3D on the GPU (`classic3d`), with 3D adjustment +/// layers splitting the run as on the CPU; Advanced 3D, wireframes and runs the kernel cannot +/// hold draw on the CPU. +fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], canvas: &mut GpuImage) -> Option<()> { + if let Some(k) = run.iter().position(|l| l.switches.adjustment) { + draw_3d(e, r, ctx, &run[..k], canvas)?; + draw_layer(e, r, ctx, run[k], canvas)?; + return draw_3d(e, r, ctx, &run[k + 1..], canvas); + } + if run.is_empty() { + return Some(()); + } + match r.prepare_3d_run(ctx, run, (canvas.width, canvas.height)).and_then(|prep| crate::classic3d::draw_run(e, &prep, canvas)) { + Some(img) => *canvas = img, + None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?, + } + Some(()) +} + fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> { let opacity = r.layer_opacity(ctx, layer); if opacity <= 0.0 { @@ -113,9 +133,14 @@ fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: & } fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { - if layer.switches.adjustment || layer.switches.quality == Quality::Wireframe { - // Adjustment layers run their effects on the comp below (CPU, with GPU effects where - // available); wireframes draw outlines. + if layer.switches.adjustment { + if draw_adjustment(e, r, ctx, layer, canvas).is_some() { + return Some(()); + } + return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img)); + } + if layer.switches.quality == Quality::Wireframe { + // Wireframes draw outlines on the CPU. return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img)); } let opacity = r.layer_opacity(ctx, layer); @@ -151,6 +176,33 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: & Some(()) } +/// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below, +/// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result +/// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU. +fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> { + 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 pipe = r.pipe(); + // Effects run in the working space, not the linear blending space. + let below = match pipe.from_blend() { + Some(c) => ops::convert(e, canvas, &c), + None => canvas.clone(), + }; + let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels); + r.run_effects_on(ctx, layer, true, &mut fx); + let (mut adjusted, _, _) = fx.finish()?; + if adjusted.width != w || adjusted.height != h { + return Some(()); + } + if let Some(c) = pipe.to_blend() { + adjusted = ops::convert(e, &adjusted, &c); + } + *canvas = ops::adjust_mix(e, canvas, &adjusted, &matte, opacity); + Some(()) +} + fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc, canvas: &mut GpuImage, opacity: f32) -> Option<()> { let matte = r.track_matte(ctx, layer).is_some(); if !matte && !layer.preserve_transparency { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index aa990f0e..fd9be466 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -82,6 +82,9 @@ struct FxHost<'r, 'a, 'c> { const MAX_FX_DEPTH: usize = 8; impl EffectHost for FxHost<'_, '_, '_> { + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + self.r.active_accel().and_then(|a| a.particles()) + } fn layer(&self, id: u64, masks_and_effects: bool) -> Option { let other = self.ctx.layer(effectcraft_project::LayerId(id))?; if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { @@ -240,6 +243,46 @@ pub trait Accelerator: Send + Sync { fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option { None } + /// A particle simulation backend (GPU particles) for the stepped particle effects, with + /// the CPU simulation's semantics. `None` = they simulate on the CPU. + fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> { + None + } +} + +/// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image +/// resident on an accelerator. +pub trait FxTarget { + /// Run a chain of accelerator-supported effects (quantising after each to the bit depth). + /// `false` = not handled: the effects then run one by one through [`FxTarget::cpu`]. + fn gpu(&mut self, steps: &[FxStep]) -> bool; + /// Run one CPU effect: `f` maps the buffer. + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf); +} + +/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback). +struct CpuFx<'a> { + accel: Option<&'a dyn Accelerator>, + levels: Option, + buf: Option, +} + +impl FxTarget for CpuFx<'_> { + fn gpu(&mut self, steps: &[FxStep]) -> bool { + let (Some(a), Some(b)) = (self.accel, &self.buf) else { return false }; + match a.effects(steps, b, self.levels) { + Some(out) => { + self.buf = Some(out); + true + } + None => false, + } + } + fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) { + if let Some(b) = self.buf.take() { + self.buf = Some(f(b)); + } + } } #[derive(Clone, Copy, Debug)] @@ -494,11 +537,8 @@ 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 { - // Auto keeps comps with 3D layers on the CPU compositor: 3D runs are still composited on - // the CPU, and the GPU path's readback/upload around them makes such comps slower. - let auto_3d = self.opts.backend == Backend::Auto && self.project.comp(comp_id).is_some_and(|c| c.has_3d()); + // Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well. if self.depth == 0 - && !auto_3d && let Some(a) = self.active_accel() && let Some(img) = a.comp_frame(self, comp_id, t) { @@ -601,19 +641,28 @@ impl<'a> Renderer<'a> { } /// Run the first `limit` effects of the layer's stack (by position; disabled ones count). - fn apply_effects_timed( + fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf { + let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) }; + self.run_effect_stack(ctx, layer, adjustment, limit, timing, &mut t); + t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale }) + } + + /// Run the layer's video effect stack on `target`: runs of GPU-capable effects go to + /// [`FxTarget::gpu`] (when an accelerator is active and supports them), the rest to + /// [`FxTarget::cpu`] one by one (quantised to the bit depth after each). + fn run_effect_stack( &self, ctx: &EvalCtx, layer: &Layer, - mut buf: Buf, adjustment: bool, limit: usize, mut timing: Option<&mut Vec<(String, f64)>>, - ) -> Buf { + target: &mut dyn FxTarget, + ) { if !layer.switches.effects { - return buf; + return; } - let Some(fx) = layer.effects() else { return buf }; + let Some(fx) = layer.effects() else { return }; let lt = layer.layer_time(ctx.time); let size = source_size(self.project, layer); let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -644,7 +693,7 @@ impl<'a> Renderer<'a> { let mut k = 0; while k < stack.len() { let (i, g, spec) = stack[k]; - // A run of GPU effects goes to the accelerator in one upload / readback. + // A run of GPU effects goes to the accelerator in one go. if let Some(a) = accel && spec.gpu && a.supports_effect(spec.id) @@ -660,8 +709,7 @@ impl<'a> Renderer<'a> { }) .collect(); let t0 = web_time::Instant::now(); - if let Some(out) = a.effects(&steps, &buf, self.pipe.levels) { - buf = out; + if target.gpu(&steps) { if let Some(v) = timing.as_deref_mut() { let ms = t0.elapsed().as_secs_f64() * 1e3 / run.len() as f64; v.extend(run.iter().map(|(_, _, s)| (format!("{} (gpu)", s.id), ms))); @@ -676,14 +724,16 @@ impl<'a> Renderer<'a> { let env = EffectEnv { effect_index: i, ..env }; let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = web_time::Instant::now(); - buf = effectcraft_effects::apply(spec, &ectx, buf); - // 8/16 bpc effects write integer pixels. - self.pipe.quantize(&mut buf.img); + target.cpu(&mut |buf| { + let mut buf = effectcraft_effects::apply(spec, &ectx, buf); + // 8/16 bpc effects write integer pixels. + self.pipe.quantize(&mut buf.img); + buf + }); if let Some(v) = timing.as_deref_mut() { v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); } } - buf } /// Source pixels of a layer in layer space (before masks/effects). @@ -1403,6 +1453,29 @@ impl<'a> Renderer<'a> { three_d::compose::draw_run(self, ctx, run, canvas); } + /// A run of consecutive 3D layers prepared for an accelerator (planes, lights, shadow + /// casters; see [`three_d::Run3d`]) at output size `out`. `None` = draw it with + /// [`Self::draw_3d_run`] (adjustment or wireframe layers in the run, Advanced 3D). + pub fn prepare_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], out: (u32, u32)) -> Option { + three_d::compose::gpu_run(self, ctx, run, out) + } + + /// Run the layer's effect stack on `target` (an accelerator's resident image): GPU-capable + /// runs through [`FxTarget::gpu`], the others through [`FxTarget::cpu`], with the CPU's + /// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment + /// layers). + pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) { + self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, target); + } + + /// 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 { + let mut foot = self.source(ctx, layer)?; + let _ = masks::apply(ctx, layer, &mut foot); + Some(foot) + } + /// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…). pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { self.draw_layer(ctx, layer, canvas, false); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 02c7f496..55373714 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -390,7 +390,7 @@ impl crate::Accelerator for ProbeAccel { } #[test] -fn auto_backend_keeps_3d_comps_on_the_cpu() { +fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() { let (mut p, cid, comp) = setup(); p.settings.gpu_acceleration = true; let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50); @@ -404,8 +404,11 @@ fn auto_backend_keeps_3d_comps_on_the_cpu() { }; // 2D comp: Auto uses the GPU compositor. assert!(asked(&p, crate::Backend::Auto)); - // A 3D layer: Auto stays on the CPU; an explicit Gpu request still goes to the GPU. + // A 3D layer: Classic 3D runs on the GPU too (M12.7), so Auto still asks it. p.comp_mut(cid).unwrap().layers[0].switches.three_d = true; - assert!(!asked(&p, crate::Backend::Auto)); + assert!(asked(&p, crate::Backend::Auto)); assert!(asked(&p, crate::Backend::Gpu)); + // Software Only: never. + p.settings.gpu_acceleration = false; + assert!(!asked(&p, crate::Backend::Auto)); } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 732aee68..b134c88f 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -29,16 +29,18 @@ use crate::{EvalCtx, Renderer}; /// One position of a plane (several with motion blur). #[derive(Clone, Copy, Debug)] -pub(crate) struct Geo { +pub struct Geo { /// Output pixel → homogeneous layer coordinates. - hinv: Mat3, + pub hinv: Mat3, /// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`. - depth: [f64; 3], - world: Mat4, - normal: Vec3, + pub depth: [f64; 3], + /// Layer → world. + pub world: Mat4, + /// Unit world normal. + pub normal: Vec3, /// Output pixel bounds [x0, y0, x1, y1) covered by the plane. - bbox: [i64; 4], - cam: CameraState, + pub bbox: [i64; 4], + pub cam: CameraState, } /// A prepared 3D layer. @@ -72,7 +74,7 @@ impl Item<'_> { } /// Build the plane geometry of a layer for a camera. -fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { +fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option { let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1); let full = proj * world; let h = full.plane_to_mat3(); @@ -89,7 +91,6 @@ fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, return None; } let normal = normal.normalize(); - let b = buf_bounds(buf); let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])]; let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); let mut behind = 0; @@ -211,9 +212,10 @@ fn prepare_with<'a>( if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { buf = dof_blur(&cam, &dof, world, buf); } + let bounds = buf_bounds(&buf); let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { - geo(&cam, world, &buf, comp, s, out).into_iter().collect() + geo(&cam, world, bounds, comp, s, out).into_iter().collect() } else { let fd = ctx.comp.frame_duration().seconds(); let angle = ctx.comp.shutter_angle / 360.0; @@ -223,7 +225,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out) }) .collect() }; @@ -235,7 +237,6 @@ fn prepare_with<'a>( bbox = [bbox[0].min(g.bbox[0]), bbox[1].min(g.bbox[1]), bbox[2].max(g.bbox[2]), bbox[3].max(g.bbox[3])]; } let mat = Material::of(ctx, layer); - let bounds = buf_bounds(&buf); Some(Item { layer, order, @@ -364,6 +365,23 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut return; } let out = (canvas.width, canvas.height); + let g = gather(r, ctx, run, out); + let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); + composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32); + for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + r.draw_layer(ctx, l, canvas, false); + } +} + +/// The prepared planes of a run: drawn items (stack order), shadow casters outside the run, and +/// the lights. +struct Gathered<'a> { + items: Vec>, + extra: Vec>, + lights: Vec, +} + +fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, u32)) -> Gathered<'a> { // A collapsed precomp's 3D layers are lit by the outer comp's lights. let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent)); let mut items: Vec = run @@ -400,11 +418,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut .collect(); } items.sort_by_key(|i| i.order); - let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); - composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); - for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { - r.draw_layer(ctx, l, canvas, false); - } + Gathered { items, extra, lights } } /// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's @@ -624,6 +638,83 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] { out } +// ------------------------------------------------------------------ accelerator export + +/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane +/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and +/// ray-cast shadows of [`draw_run`] on another device. +#[derive(Clone, Debug)] +pub struct Run3d { + /// Drawn planes in stack order, then shadow casters from outside the run (`draw` false). + pub planes: Vec, + /// Indices (into `planes`) of the shadow casters, in the CPU's order. + pub casters: Vec, + pub lights: Vec, + /// Dissolve seed of the run. + pub seed: u32, +} + +/// One plane of a [`Run3d`]. +#[derive(Clone, Debug)] +pub struct Plane3d { + /// The layer buffer (blending space; depth of field already applied). + pub buf: Arc, + pub bicubic: bool, + /// One per motion-blur sub-sample (equal weights). + pub geos: Vec, + pub mat: Material, + pub opacity: f32, + pub draw: bool, + /// Screen-space track matte factor (output size, row-major). + pub matte: Option>>, + pub preserve: bool, + /// World → layer, and the (blurred) buffer's layer-space bounds. + pub winv: Mat4, + pub bounds: [f64; 4], + /// Output pixel bounds of all geometries. + pub bbox: [i64; 4], + /// Stack order (coplanar fragments draw lower orders first). + pub order: usize, + pub layer_id: u32, + pub mode: BlendMode, +} + +/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU +/// (adjustment, wireframe or environment background layers, Advanced 3D). +pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option { + if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || l.switches.quality == effectcraft_project::Quality::Wireframe) + { + return None; + } + if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { + return None; + } + let g = gather(r, ctx, run, out); + let planes: Vec = g + .items + .into_iter() + .chain(g.extra) + .map(|it| Plane3d { + layer_id: it.layer.id.0 as u32, + mode: it.layer.blend_mode, + buf: it.buf, + bicubic: it.bicubic, + geos: it.geos, + mat: it.mat, + opacity: it.opacity, + draw: it.draw, + matte: it.matte, + preserve: it.preserve, + winv: it.winv, + bounds: it.bounds, + bbox: it.bbox, + order: it.order, + }) + .collect(); + let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect(); + Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 }) +} + // ------------------------------------------------------------------ auxiliary render pass /// One surface seen through an output pixel (aux pass). diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index 7faaa499..b880cdcd 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -17,6 +17,7 @@ mod tests; mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; +pub use compose::{Geo as PlaneGeo, Plane3d, Run3d}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3; diff --git a/docs/architecture.md b/docs/architecture.md index 0487cbd5..a54703b7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,17 +163,31 @@ accelerator is attached and the project's renderer is the GPU). A GPU frame walk 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, knockout, all 38 blend modes (a WGSL port of `color::blend`), the 8/16 bpc clamp-and-quantise steps -and colour-space conversions run on the GPU. 3D runs, adjustment layers and wireframes run on the -CPU between GPU steps (read back, draw, upload). GPU effects (`effects::GPU_EFFECTS`: Gaussian, -Fast Box and Directional Blur, Glow, Levels, Curves, Hue/Saturation, Tint, Fill, Gradient Ramp, -Fractal Noise, Drop Shadow, Brightness & Contrast, Exposure, Invert, Transform) repeat the CPU -effect's steps (padding, box radii, parameters) as kernels; consecutive GPU effects run as one +and colour-space conversions run on the GPU. Classic 3D runs composite on the GPU too +(`gpu::classic3d`): the render crate prepares the planes (`Renderer::prepare_3d_run`: layer buffers +with their bokeh depth of field, homographies per motion-blur sub-sample, materials, lights, track +mattes), the GPU packs the buffers into one atlas texture and one kernel does the per-pixel +fragment sort (intersecting planes, coplanar stack order), Blinn-Phong lighting, ray-cast shadows +against the caster planes (Shadow Diffusion, Light Transmission) and blending. Adjustment layers +run their effect stacks on the GPU-resident comp (`Renderer::run_effects_on` with an `FxTarget`): +runs of GPU effects stay on the device and only non-GPU effects read back and upload. Advanced 3D +compositing and wireframes still run on the CPU between GPU steps (read back, draw, upload). GPU +effects (`effects::GPU_EFFECTS`, 58 of them: blurs, colour, keying incl. Key Light, distortion, +transitions, generators, noise and grain; see [effects.md](effects.md)) repeat the CPU effect's +steps (padding, box radii, parameters, hashes) as kernels; consecutive GPU effects run as one chain with one upload and one readback. 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 `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 -Info panel's pixel readout reads GPU frames back asynchronously). +Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles** +(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle +Playground cannons without interacting forces evolve every particle independently, so the GPU +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. **Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are `f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and diff --git a/docs/effects.md b/docs/effects.md index a7717d75..84735286 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -5,7 +5,7 @@ EffectCraft ships 304 effects with 2446 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**: 25 effects also run on the GPU compositor with identical results. +- **GPU**: 59 effects also run on the GPU compositor with identical results. - **32**: 304 effects process 32-bit float (HDR, overbright) pixels without clamping. - **Status**: 262 implemented in full, 42 partial (what is missing is listed). @@ -49,16 +49,16 @@ Effects are addressed by id (`ec..`) or display name in commands | Bilateral Blur | `ec.blur.bilateral` | 3 | | 32 | Implemented | | CC Cross Blur | `ec.blur.cccross` | 3 | | 32 | Implemented | | CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented | -| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | | 32 | Implemented | +| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented | | CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented | -| Camera Lens Blur | `ec.blur.cameralens` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer | +| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer | | Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented | | Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented | | Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented | | Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented | | Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented | | Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented | -| Radial Blur | `ec.blur.radial` | 4 | | 32 | Implemented | +| Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented | | Sharpen | `ec.blur.sharpen` | 1 | | 32 | Implemented | | Smart Blur | `ec.blur.smart` | 3 | | 32 | Implemented | | Unsharp Mask | `ec.blur.unsharp` | 3 | | 32 | Implemented | @@ -88,7 +88,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Auto Color | `ec.color.autocolor` | 6 | | 32 | Implemented | | Auto Contrast | `ec.color.autocontrast` | 5 | | 32 | Implemented | | Auto Levels | `ec.color.autolevels` | 5 | | 32 | Implemented | -| Black & White | `ec.color.blackwhite` | 8 | | 32 | Implemented | +| Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented | | Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented | | Broadcast Colors | `ec.color.broadcast` | 3 | | 32 | Implemented | | CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | | 32 | Implemented | @@ -97,12 +97,12 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Toner | `ec.color.cctoner` | 7 | | 32 | Implemented | | Change Color | `ec.color.changecolor` | 9 | | 32 | Implemented | | Change to Color | `ec.color.changetocolor` | 9 | | 32 | Implemented | -| Channel Mixer | `ec.color.channelmixer` | 13 | | 32 | Implemented | -| Color Balance | `ec.color.colorbalance` | 10 | | 32 | Implemented | +| Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented | +| Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented | | Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented | | Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented | | Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented | -| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | +| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking | | Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup | | Equalize | `ec.color.equalize` | 2 | | 32 | Implemented | | Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented | @@ -111,7 +111,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented | | Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) | | Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | -| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | +| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | | OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented | | OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented | @@ -119,11 +119,11 @@ Effects are addressed by id (`ec..`) or display name in commands | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | | Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented | -| Selective Color | `ec.color.selectivecolor` | 37 | | 32 | Partial: simplified Colors / Details layout | +| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout | | Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented | | Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented | -| Tritone | `ec.color.tritone` | 4 | | 32 | Implemented | -| Vibrance | `ec.color.vibrance` | 2 | | 32 | Implemented | +| Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented | +| Vibrance | `ec.color.vibrance` | 2 | GPU | 32 | Implemented | | Video Limiter | `ec.color.videolimiter` | 5 | | 32 | Implemented | ## Distort @@ -131,13 +131,13 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| | Bezier Warp | `ec.distort.bezierwarp` | 13 | | 32 | Implemented | -| Bulge | `ec.distort.bulge` | 6 | | 32 | Implemented | +| Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented | | CC Bend It | `ec.distort.ccbendit` | 4 | | 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 | | 32 | Implemented | | CC Griddler | `ec.distort.ccgriddler` | 5 | | 32 | Implemented | -| CC Lens | `ec.distort.cclens` | 3 | | 32 | Implemented | +| CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented | | CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented | | CC Power Pin | `ec.distort.ccpowerpin` | 10 | | 32 | Implemented | | CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | | 32 | Implemented | @@ -148,27 +148,27 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Tiler | `ec.distort.cctiler` | 3 | | 32 | Implemented | | Corner Pin | `ec.distort.cornerpin` | 4 | | 32 | Implemented | | Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented | -| Displacement Map | `ec.distort.displacementmap` | 8 | | 32 | Implemented | +| Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented | | Liquify | `ec.distort.liquify` | 12 | | 32 | Implemented | | Magnify | `ec.distort.magnify` | 10 | | 32 | Implemented | -| Mesh Warp | `ec.distort.meshwarp` | 3 | | 32 | Implemented | +| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented | | Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented | | Offset | `ec.distort.offset` | 2 | | 32 | Implemented | | Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels | | Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented | | Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented | | Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points | -| Ripple | `ec.distort.ripple` | 7 | | 32 | Implemented | +| Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented | | Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect | | Smear | `ec.distort.smear` | 8 | | 32 | Implemented | | Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented | | Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur | -| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Partial: no locked pinning variants | -| Twirl | `ec.distort.twirl` | 3 | | 32 | Implemented | +| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Partial: no locked pinning variants | +| Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented | | Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented | | Warp | `ec.distort.warp` | 5 | | 32 | Implemented | | Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented | -| Wave Warp | `ec.distort.wavewarp` | 7 | | 32 | Implemented | +| Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented | ## Expression Controls @@ -188,7 +188,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented | +| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented | | Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing | | Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented | | Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented | @@ -198,15 +198,15 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented | | CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented | | CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented | -| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Partial: HQ variants render like the standard ones | -| Checkerboard | `ec.generate.checkerboard` | 10 | | 32 | Implemented | +| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones | +| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented | | Circle | `ec.generate.circle` | 11 | | 32 | Implemented | | Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented | | Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented | | Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather | | Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented | | Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented | -| Grid | `ec.generate.grid` | 12 | | 32 | Implemented | +| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented | | Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented | | Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented | | Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile | @@ -244,8 +244,8 @@ Effects are addressed by id (`ec..`) or display name in commands | Extract | `ec.key.extract` | 6 | | 32 | Implemented | | Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes | | Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented | -| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | -| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented | +| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing | +| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented | | Screen Key | `ec.key.screen` | 11 | | 32 | Implemented | | Unmult | `ec.key.unmult` | 6 | | 32 | Implemented | @@ -264,14 +264,14 @@ Effects are addressed by id (`ec..`) or display name in commands | Effect | Id | Params | GPU | 32 | Status | |---|---|---:|:-:|:-:|---| -| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls | +| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls | | Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented | | Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented | | Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented | | Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls | | Median | `ec.noise.median` | 2 | | 32 | Implemented | | Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented | -| Noise | `ec.noise.noise` | 3 | | 32 | Implemented | +| Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented | | Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented | | Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented | | Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented | @@ -354,10 +354,10 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented | | Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement | | Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented | -| Emboss | `ec.stylize.emboss` | 4 | | 32 | Implemented | -| Find Edges | `ec.stylize.findedges` | 2 | | 32 | Implemented | +| Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented | +| Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented | | Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map | -| Mosaic | `ec.stylize.mosaic` | 3 | | 32 | Implemented | +| Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented | | Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented | | Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented | | Roughen Edges | `ec.stylize.roughenedges` | 13 | | 32 | Implemented | @@ -402,11 +402,11 @@ Effects are addressed by id (`ec..`) or display name in commands | CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented | | CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented | | Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter | -| Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented | +| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented | | Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented | -| Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented | -| Radial Wipe | `ec.transition.radialwipe` | 5 | | 32 | Implemented | -| Venetian Blinds | `ec.transition.venetian` | 4 | | 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 | ## Utility diff --git a/docs/parity.md b/docs/parity.md index 38df7882..4437bece 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -20,12 +20,11 @@ Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking -renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU -rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates; +renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates; approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and -the "better than After Effects" items (a plug-in API, branching history, GPU particles). +the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. @@ -120,11 +119,11 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (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 | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | | 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; 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 (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | -| Effects | ≈ 80% | 4.5 | GPU versions of more effects 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 | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) 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 | a richer Learn area (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 | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (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 | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -171,8 +170,8 @@ taper/wave, Advanced 3D's DOF has no iris shapes, and variable axes don't change 1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10). 2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors. 3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13) -4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; 3D runs - and adjustment layers still composite on the CPU). +4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; M12.7: + Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles). 5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer. 6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8) 7. Point tracking and stabilization (in progress).