Merge branch 'worktree-agent-a8dea01e1a1d42946'

This commit is contained in:
Brandon Thomas 2026-10-03 03:18:47 -04:00
commit ebcb8c95ce
41 changed files with 6198 additions and 166 deletions

View File

@ -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<ItemId> {
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<ItemId> = 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<ItemId> = 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(())

View File

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

View File

@ -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<Pt> {
/// 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| {

View File

@ -75,7 +75,7 @@ pub(crate) const GEN_BLEND_MODES: [&str; 20] = [
"Silhouette Alpha",
];
fn gen_mode(i: u32) -> Option<BlendMode> {
pub fn gen_mode(i: u32) -> Option<BlendMode> {
Some(match i {
0 => return None,
2 => BlendMode::Add,

View File

@ -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,

View File

@ -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<std::sync::Arc<AuxChannels>> {
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

View File

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

106
crates/effects/src/psim.rs Normal file
View File

@ -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<Vec<SimParticle>>;
}
/// 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<Vec<u32>> {
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;

View File

@ -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<std::sync::Arc<PState>> {
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<Vec<crate::psim::SimParticle>> {
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<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
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<PState> = 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<PState> = 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");

View File

@ -231,6 +231,38 @@ impl Pg {
static PG_CACHE: SimCache<PgState> = 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<std::sync::Arc<PgState>> {
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<Vec<crate::psim::SimParticle>> {
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<Sprite> = st
.parts

View File

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

233
crates/gpu/src/classic3d.rs Normal file
View File

@ -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<u8>);
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<u8> {
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<usize> = (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<GpuImage> {
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<f32> = vec![];
let mut matte_ix: HashMap<usize, u32> = 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);
}
}

View File

@ -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<Uploads>,
/// Advanced 3D render pipelines (built on first use).
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
/// GPU particle pipeline (built on first use) and simulation checkpoints.
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<u8>) -> 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 })

View File

@ -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<GBuf>,
levels: Option<f32>,
}
impl<'e, 'g> GpuFx<'e, 'g> {
pub fn new(e: &'e mut Enc<'g>, img: GpuImage, offset: [f64; 2], scale: f64, levels: Option<f32>) -> 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<GBuf> {
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<GBuf> {
"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<GBuf> {
// ---------------------------------------------------------------- blurs
/// raster::blur::box_radii.
fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
pub(crate) fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
if sigma <= 0.0 {
return vec![0; n];
}
@ -95,7 +140,7 @@ fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
}
/// 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)

396
crates/gpu/src/fx_color.rs Normal file
View File

@ -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<GBuf> {
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<GBuf> {
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<f32> = 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<GBuf> {
let pr = ctx.params;
let f = |k: &str| (pr.f(k) / 100.0) as f32;
let mut d: Vec<f32> = 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<GBuf> {
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 })
}

View File

@ -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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<f32> = Vec::with_capacity(nx * ny * 24);
let mut buckets: Vec<Vec<u32>> = 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<GBuf> {
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<f32> = (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<usize> = (0..w).map(|x| ((x as f64 / cw) as usize).min(nx - 1)).collect();
let ty: Vec<usize> = (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<f32> { (0..=n).map(|i| t.partition_point(|&v| v < i) as f32).collect() };
let mut data: Vec<f32> = 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<GBuf> {
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<GBuf> {
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)
}

View File

@ -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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<f32> = 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<GBuf> {
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<f64> = 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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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<GBuf> {
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)
}

View File

@ -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<effectcraft_render::three_d::adv::Target> {
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<Vec<effectcraft_effects::psim::SimParticle>> {
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;

View File

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

209
crates/gpu/src/particles.rs Normal file
View File

@ -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<Checkpoint>,
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<u8> {
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<Vec<SimParticle>> {
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<u8> = 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<f32> = 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<Pipes>;
pub(crate) type StatesCell = Mutex<Checkpoints>;

View File

@ -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<f32>,
// Buffer offset (x, y), scale, bicubic (0 / 1).
tex: vec4<f32>,
// Output pixel bounds [x0, y0, x1, y1).
bbox: vec4<f32>,
// Layer-space bounds of the buffer.
bounds: vec4<f32>,
// World → layer (affine rows).
winv0: vec4<f32>,
winv1: vec4<f32>,
winv2: vec4<f32>,
// Ambient, diffuse, specular, Blinn-Phong exponent.
mat0: vec4<f32>,
// Metal, light transmission, opacity.
mat1: vec4<f32>,
// First geometry, geometry count, flags (1 draw, 2 preserve transparency, 4 accepts
// lights), blend mode.
ints: vec4<u32>,
// Casts shadows, accepts shadows (0 off, 1 on, 2 only), layer id, stack order.
ints2: vec4<u32>,
// Track matte (index + 1; 0 = none), dissolve seed.
ints3: vec4<u32>,
}
struct Geo {
// Output pixel → homogeneous layer coordinates (rows).
h0: vec4<f32>,
h1: vec4<f32>,
h2: vec4<f32>,
// Camera depth = d.x·u + d.y·v + d.z.
depth: vec4<f32>,
// Layer → world (affine rows).
w0: vec4<f32>,
w1: vec4<f32>,
w2: vec4<f32>,
// Unit world normal.
normal: vec4<f32>,
// Camera eye (xyz) and orthographic flag (w).
eye: vec4<f32>,
// Camera forward (xyz).
fwd: vec4<f32>,
}
struct Light {
// Kind (0 ambient, 1 parallel, 2 point, 3 spot), falloff, casts shadows.
ints: vec4<u32>,
pos: vec4<f32>,
dir: vec4<f32>,
color: vec4<f32>,
// Cone half-angle, feather, radius, falloff distance.
cone: vec4<f32>,
// Shadow darkness, shadow diffusion.
shadow: vec4<f32>,
}
@group(1) @binding(0) var<storage, read> planes: array<Plane>;
@group(1) @binding(1) var<storage, read> geos: array<Geo>;
@group(1) @binding(2) var<storage, read> lights: array<Light>;
@group(1) @binding(3) var<storage, read> casters: array<u32>;
const NEAR3: f32 = 1.0;
const MAX_FRAGS: u32 = 32u;
fn atlas_get(i: u32, x: i32, y: i32) -> vec4<f32> {
let r = planes[i].atlas;
if (x < 0 || y < 0 || x >= i32(r.z) || y >= i32(r.w)) {
return vec4<f32>(0.0);
}
return textureLoad(src, vec2<i32>(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<f32> {
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<f32>(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<f32>(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<f32>, r1: vec4<f32>, r2: vec4<f32>, p: vec3<f32>) -> vec3<f32> {
return vec3<f32>(dot(r0.xyz, p) + r0.w, dot(r1.xyz, p) + r1.w, dot(r2.xyz, p) + r2.w);
}
fn linear3(r0: vec4<f32>, r1: vec4<f32>, r2: vec4<f32>, v: vec3<f32>) -> vec3<f32> {
return vec3<f32>(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<f32> {
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<f32>(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<f32>(0.0);
}
return vec4<f32>(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<f32>) -> vec4<f32> {
let li = lights[l];
if (li.ints.x == 1u) {
return vec4<f32>(-li.dir.xyz, -1.0);
}
let d = li.pos.xyz - p;
let len = length(d);
if (len < 1e-9) {
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
}
return vec4<f32>(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>) -> 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<f32>, l: u32) -> vec3<f32> {
let pl = planes[c];
let li = lights[l];
let a = affine(pl.winv0, pl.winv1, pl.winv2, p);
var hit: vec3<f32>;
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<f32>(1.0);
}
let s = -a.z / dl.z;
if (s <= 1.0 || s >= 1.0e6) {
return vec3<f32>(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<f32>(1.0);
}
let t = a.z / (a.z - b.z);
if (t <= 1e-6 || t >= 1.0) {
return vec3<f32>(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<f32>(1.0);
}
var px = vec4<f32>(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<f32>(1.0);
}
let tr = pl.mat1.y;
let dark = li.shadow.x;
var o = vec3<f32>(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<f32>) -> vec3<f32> {
var tr = vec3<f32>(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<f32>) -> vec4<f32> {
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<f32>(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<f32>(u, v, 0.0));
var to_viewer: vec3<f32>;
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<f32>(0.0);
var diff = vec3<f32>(0.0);
var spec = vec3<f32>(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<f32>(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<f32>(1.0) + (rgb - vec3<f32>(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<f32>(0.0, 0.0, 0.0, a * shadow_amt);
}
return vec4<f32>(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<u32>) {
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<i32>(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<f32, MAX_FRAGS>;
var fo: array<u32, MAX_FRAGS>;
var fi: array<u32, MAX_FRAGS>;
var fp: array<vec4<f32>, 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<f32>(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);
}

View File

@ -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<f32>) -> vec3<f32> {
if (px.w > 1e-6) {
return px.xyz / px.w;
}
return vec3<f32>(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<f32>, b: vec3<f32>, t: f32) -> vec3<f32> {
return a + (b - a) * t;
}
// color::hsv_to_rgb.
fn fxc_hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32> {
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<f32>(v, t, p); }
case 1: { return vec3<f32>(q, v, p); }
case 2: { return vec3<f32>(p, v, t); }
case 3: { return vec3<f32>(p, q, v); }
case 4: { return vec3<f32>(t, p, v); }
default: { return vec3<f32>(v, p, q); }
}
}
fn fxc_pixel(gid: vec3<u32>) -> vec2<i32> {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return vec2<i32>(-1);
}
return p;
}
// ---------------------------------------------------------------- per-pixel colour effects
// Colorama's input phase (color_fx::colorama_phase).
fn fxc_colorama_phase(c: vec3<f32>, 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<f32>) -> array<f32, 9> {
let c = clamp(c0, vec3<f32>(0.0), vec3<f32>(1.0));
// Stable descending sort of the channel indices.
var idx = vec3<u32>(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<f32, 9>(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
var prim = array<u32, 3>(0u, 2u, 4u);
var sec = array<u32, 3>(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<u32>) {
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<f32>(0.0));
if (P.u[0].y != 0u) {
let d = luminance(c) - luminance(o);
o = max(o + d, vec3<f32>(0.0));
}
px = vec4<f32>(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<f32>(hsl_to_rgb(hsl.x, s, hsl.z) * a, a);
}
}
case 3u: {
if (a > 0.0) {
let c = px.xyz / a;
var w = array<f32, 6>(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<f32>(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<f32>(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<f32>;
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<f32>(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<f32>(o * a, a);
}
}
case 6u: {
if (a > 0.0) {
let c = px.xyz / a;
var o: vec3<f32>;
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<f32>(o.x);
}
px = vec4<f32>(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<f32>(max(o, vec3<f32>(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<f32>(1.0 / 3.0, 1.0 / 3.0);
if (s > 1e-6) {
pc = vec2<f32>(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<f32>(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<f32> {
return vec3<f32>(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<u32>) {
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<f32>(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<f32>(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<f32>(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<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let px = textureLoad(src, p, 0);
textureStore(out, p, vec4<f32>(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<u32>) {
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<f32>(max(c + d, vec3<f32>(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<u32>) {
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<u32>) {
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<f32>, bias: vec3<f32>) -> vec3<f32> {
let l = max(luminance(bias), 1e-4);
return vec3<f32>(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<f32>, ix: vec3<u32>, bal: f32) -> f32 {
return c[ix.x] - (bal * c[ix.y] + (1.0 - bal) * c[ix.z]);
}
fn fxc_correct(c0: vec3<f32>, sat: f32, contrast: f32, bright: f32) -> vec3<f32> {
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<u32>) {
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<f32>(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<u32>) {
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<f32>(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<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
textureStore(out, p, vec4<f32>(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<u32>) {
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<f32>(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<u32>) {
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<f32>(pl.x, pl.y, e, 0.0));
}
fn fxc_kl_replace(fg: vec3<f32>, c: vec3<f32>, method: u32, rc: vec3<f32>, amt: f32) -> vec3<f32> {
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<u32>) {
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<f32>(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<f32>;
switch view {
case 1u: { o = vec4<f32>(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<f32>(cs, 1.0);
}
case 3u: { o = vec4<f32>(edge, edge, edge, 1.0); }
case 4u: { o = vec4<f32>(sm, sm, sm, 1.0); }
case 5u, 6u: { o = vec4<f32>(0.0, 0.0, 0.0, 1.0); }
case 7u: { o = vec4<f32>(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<f32>(v, v, v, 1.0);
}
case 9u: { o = vec4<f32>(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<f32>(0.0);
} else if (P.u[1].z != 0u) {
o = vec4<f32>(fg * a, a);
} else {
o = vec4<f32>(fg * a * a, a);
}
}
}
textureStore(out, p, o);
}

View File

@ -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<i32>) -> vec2<f32> {
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5);
}
fn dst_inside(p: vec2<i32>) -> 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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>(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>) -> 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<u32>) {
let p = vec2<i32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<f32>(gx * k, gy * k);
} else {
d = vec2<f32>(-gy * k, gx * k);
}
}
case 6u: {
d = vec2<f32>(0.0, dst_turb_n(u, v, 17u) * amount);
}
case 7u: {
d = vec2<f32>(dst_turb_n(u, v, 0u) * amount, 0.0);
}
case 8u: {
let a = dst_turb_n(u, v, 0u) * amount;
d = vec2<f32>(a, a);
}
default: {
d = vec2<f32>(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<f32>, 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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(0.0);
for (var x = u32(data[xb]); x < u32(data[xb + 1u]); x++) {
s += textureLoad(src, vec2<i32>(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<u32>) {
let p = vec2<i32>(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<f32>(0.0);
for (var y = y0; y < y1; y++) {
s += textureLoad(src, vec2<i32>(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<u32>) {
let p = vec2<i32>(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<i32>(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<u32>) {
let p = vec2<i32>(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<f32>, fx: vec4<f32>, k: f32) -> vec4<f32> {
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<u32>) {
let p = vec2<i32>(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<f32>(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<u32>) {
let p = vec2<i32>(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<f32>(v * al, v * al, v * al, al), P.f[0].w));
}

View File

@ -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<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(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<f32>, g: vec4<f32>, mode: u32) -> vec4<f32> {
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<f32> {
return vec2<f32>(data[i], data[i + 1u]);
}
// (a + b) for (hi, lo) pairs whose hi parts cancel near zero.
fn gen_sum(a: vec2<f32>, b: vec2<f32>) -> 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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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>(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y);
var acc = vec4<f32>(0.0);
for (var i = 0u; i < n; i++) {
var s: vec2<f32>;
if (zoom) {
let k = data[i];
s = vec2<f32>(c.x + v.x * k, c.y + v.y * k);
} else {
let sn = data[2u * i];
let cs = data[2u * i + 1u];
s = vec2<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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>(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<f32>(0.0);
if (mode == 1u) {
acc = vec4<f32>(-3.0e38);
} else if (mode == 2u) {
acc = vec4<f32>(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<f32>(max(min(acc.xyz, vec3<f32>(a)), vec3<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32>((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<u32>) {
let dims = vec2<i32>(textureDimensions(src));
let y = i32(gid.x);
if (y >= dims.y) {
return;
}
var acc = vec4<f32>(0.0);
textureStore(out, vec2<i32>(0, y), acc);
for (var x = 0; x < dims.x; x++) {
acc += textureLoad(src, vec2<i32>(x, y), 0);
textureStore(out, vec2<i32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<i32>(clamp(i, 0, w - 1), yy), 0);
} else if (i >= 0 && i < w) {
acc += textureLoad(src, vec2<i32>(i, yy), 0);
}
}
continue;
}
if (repeat) {
if (a < 0) {
let k = min(-a, bnd - a + 1);
acc += textureLoad(src, vec2<i32>(0, yy), 0) * f32(k);
a = 0;
}
if (bnd >= w) {
let k = min(bnd - (w - 1), bnd - a + 1);
acc += textureLoad(src, vec2<i32>(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<i32>(bnd + 1, yy), 0) - textureLoad(aux, vec2<i32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32> {
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<u32>(tx);
let hy = bitcast<u32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let bl = P.f[8].x;
var acc = vec3<f32>(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<f32>(n);
}
let op = P.f[8].z;
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>((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<f32>(clamp(px.xyz + n * a, vec3<f32>(0.0), vec3<f32>(a)), a);
} else {
// Unclipped: values past black / white wrap around.
px = vec4<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32>(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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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<f32>(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<f32>(
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<f32>(o * a, a));
}

View File

@ -828,3 +828,19 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
}
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<u32>) {
let dims = out_dims();
let p = vec2<i32>(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);
}

View File

@ -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<u32>,
// Animation, flat, seed, particles in `prev`.
u2: vec4<u32>,
// 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<vec4<f32>, 6>,
}
struct PState {
// Position, age.
p: vec4<f32>,
// Velocity, life.
v: vec4<f32>,
// rnd, alive (1 / 0), id.
m: vec4<f32>,
}
@group(1) @binding(0) var<uniform> S: PSim;
@group(1) @binding(1) var<storage, read> births: array<u32>;
@group(1) @binding(2) var<storage, read> prev: array<PState>;
@group(1) @binding(3) var<storage, read_write> next: array<PState>;
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<f32>) -> vec3<f32> {
let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
if (l > 1e-9) {
return v / l;
}
return vec3<f32>(0.0, -1.0, 0.0);
}
// sim::rand_dir.
fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3<f32> {
if (flat) {
let a = ps_h(i, k) * PS_TAU;
return vec3<f32>(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<f32>(r * cos(a), r * sin(a), z);
}
struct Part {
p: vec3<f32>,
v: vec3<f32>,
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<f32>(jitter.x, 0.0, z);
}
case 6u: {
dir = ps_norm3(vec3<f32>(jitter.x * 0.25, -1.0, jitter.z * 0.25));
}
case 7u: {
dir = ps_norm3(vec3<f32>(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<f32>(sin(a), -cos(a));
let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0);
var off: vec2<f32>;
if (r >= 0.0) {
off = vec2<f32>(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<f32>(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<f32>(p, 0.0), vec3<f32>(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<function, Part>, 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<f32>(v, 0.0);
(*q).p = vec3<f32>(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<u32>) {
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<f32>(q.p, q.age), vec4<f32>(q.v, q.life), vec4<f32>(q.rnd, select(0.0, 1.0, alive), 0.0, 0.0));
}

View File

@ -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<Option<Gpu>> = 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<Diff> {
pub(crate) fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option<Diff> {
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<Diff> {
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<Diff>, allow: f64) {
pub(crate) fn check(label: &str, d: Option<Diff>, 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])
}

220
crates/gpu/src/tests_3d.rs Normal file
View File

@ -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:?}");
}

View File

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

View File

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

View File

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

View File

@ -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))],
);
}

View File

@ -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<LayerPixels> {
None
}
fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
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<SimParticle>, Vec<SimParticle>) {
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<u32, &SimParticle> = 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::<f32>() / 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);
}
}
}

View File

@ -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<Buf>, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
let matte = r.track_matte(ctx, layer).is_some();
if !matte && !layer.preserve_transparency {

View File

@ -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<LayerPixels> {
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<three_d::adv::Target> {
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<f32>,
buf: Option<Buf>,
}
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: &params, 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::Run3d> {
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<Buf> {
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);

View File

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

View File

@ -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<Geo> {
fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option<Geo> {
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<Geo> = 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<Item<'a>>,
extra: Vec<Item<'a>>,
lights: Vec<LightState>,
}
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<Item> = 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<Plane3d>,
/// Indices (into `planes`) of the shadow casters, in the CPU's order.
pub casters: Vec<usize>,
pub lights: Vec<LightState>,
/// 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<Buf>,
pub bicubic: bool,
/// One per motion-blur sub-sample (equal weights).
pub geos: Vec<Geo>,
pub mat: Material,
pub opacity: f32,
pub draw: bool,
/// Screen-space track matte factor (output size, row-major).
pub matte: Option<Arc<Vec<f32>>>,
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<Run3d> {
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<Plane3d> = 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).

View File

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

View File

@ -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

View File

@ -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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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.<category>.<name>`) 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

View File

@ -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).