M12.2: gpu compositor and gpu effects

New L3 crate effectcraft-gpu: a wgpu compute backend for the per-frame
compositing walk (layer transforms with the CPU's nearest/bilinear/bicubic
sampling and minification pre-filter, motion-blur accumulation, track
mattes, Preserve Transparency, layer style passes and knockout, all 38
blend modes as a WGSL port of color::blend, 8/16 bpc clamp+quantise,
colour-space conversions). 3D runs, adjustment and wireframe layers run on
the CPU between GPU steps. GPU effects with the CPU semantics: Gaussian,
Fast Box and Directional Blur, Glow, Levels, Curves, Hue/Saturation, Tint,
Fill, Gradient Ramp, Fractal Noise, Drop Shadow, Brightness & Contrast,
Exposure, Invert, Transform (registry gpu badge from effects::GPU_EFFECTS).

render: Backend (Cpu/Gpu/Auto) in RenderOpts, the Accelerator trait and
Renderer::accel, GPU effect chains in the effect stack, hooks for the GPU
walk. Project setting gpu_acceleration (Mercury GPU Acceleration / Software
Only), render.backend command, file.projectSettings renderer. Session and
Render Queue exports carry the accelerator. CLI --gpu and bench --gpu.

Tests compare GPU vs CPU renders (blend modes x bit depths, transforms,
masks, mattes, motion blur, colour management, styles, collapse,
adjustment fallback, every GPU effect) and skip without an adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:59:08 -04:00
parent ab3b4cfb70
commit 485f562e19
30 changed files with 3625 additions and 62 deletions

21
Cargo.lock generated
View File

@ -1157,6 +1157,7 @@ dependencies = [
"cpu-time",
"effectcraft-automation",
"effectcraft-engine",
"effectcraft-gpu",
"effectcraft-host",
"effectcraft-render",
"effectcraft-time",
@ -1256,6 +1257,25 @@ dependencies = [
"serde",
]
[[package]]
name = "effectcraft-gpu"
version = "0.1.0"
dependencies = [
"bytemuck",
"effectcraft-color",
"effectcraft-effects",
"effectcraft-geom",
"effectcraft-keyframe",
"effectcraft-project",
"effectcraft-raster",
"effectcraft-render",
"effectcraft-text",
"effectcraft-time",
"log",
"pollster 0.4.0",
"wgpu",
]
[[package]]
name = "effectcraft-host"
version = "0.1.0"
@ -1406,6 +1426,7 @@ dependencies = [
"effectcraft-color",
"effectcraft-engine",
"effectcraft-geom",
"effectcraft-gpu",
"effectcraft-host",
"effectcraft-keyframe",
"effectcraft-path",

View File

@ -23,6 +23,7 @@ effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-track = { path = "crates/track" }
effectcraft-render = { path = "crates/render" }
effectcraft-gpu = { path = "crates/gpu" }
effectcraft-media = { path = "crates/media" }
effectcraft-export = { path = "crates/export" }
effectcraft-expr = { path = "crates/expr" }
@ -66,6 +67,11 @@ filmcraft-prores = { git = "https://github.com/storytold/filmcraft", rev = "89cf
filmcraft-aac = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-isobmff = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
# gpu (the same wgpu eframe / egui-wgpu use)
wgpu = { version = "30.0.1", features = ["fragile-send-sync-non-atomic-wasm"] }
bytemuck = "1"
pollster = "0.4"
# ui
egui = "0.36"
eframe = { version = "0.36", default-features = false }
@ -108,6 +114,9 @@ opt-level = 3
[profile.dev.package.effectcraft-render]
opt-level = 3
[profile.dev.package.effectcraft-gpu]
opt-level = 3
[profile.dev.package.effectcraft-effects]
opt-level = 3

View File

@ -17,6 +17,7 @@ effectcraft-engine = { workspace = true }
effectcraft-host = { workspace = true }
effectcraft-automation = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-gpu = { workspace = true }
effectcraft-time = { workspace = true }
serde_json = { workspace = true }
image = { workspace = true }

View File

@ -13,11 +13,14 @@
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] render timings
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
//!
//! Project: --project F.ecproj (or a positional *.ecproj) | --demo | --empty (default: demo; mcp: empty)
//! Saving: --save (back to --project) | --save-as F.ecproj
//! GPU: --gpu renders on the GPU compositor (Mercury GPU Acceleration) when an adapter exists;
//! the default is the CPU (Mercury Software Only)
//! Bridge: --bridge PORT drives a running `effectcraft --control PORT` instead of a headless session
//! Output: --json for one compact JSON document on stdout (errors: {"error": …}, exit 1)
//! ```
@ -41,10 +44,11 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off] | --queue
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
--play N renders N consecutive frames with/without the layer cache;
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
mcp [--bridge PORT] MCP server (JSON-RPC over stdio)
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json
options: --project F.ecproj | --demo | --empty --save | --save-as F --bridge PORT --json --gpu
<comp>: id or name, '-' = active comp; <layer>: id, '#n' or name; <value>: JSON or bare string";
/// Options that take a value.
@ -196,7 +200,7 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
}
return Ok(Backend::bridge(addr)?);
}
let mut b = Backend::headless(effectcraft_host::session());
let mut b = Backend::headless(session(args)?);
if let Some(p) = &args.project {
b.exec("file.open", json!({"path": p}))?;
} else if args.flag("--demo") || (default_demo && !args.flag("--empty")) {
@ -205,6 +209,17 @@ fn backend(args: &Args, default_demo: bool) -> Result<Backend, Failure> {
Ok(b)
}
/// A wired session; `--gpu` attaches the GPU compositor (renders then follow the project's
/// renderer setting, Mercury GPU Acceleration by default).
fn session(args: &Args) -> Result<effectcraft_engine::Session, Failure> {
let mut s = effectcraft_host::session();
if args.flag("--gpu") {
let g = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
s.accel = Some(std::sync::Arc::new(g));
}
Ok(s)
}
/// `--save` / `--save-as` after a mutating command.
fn maybe_save(b: &mut Backend, args: &Args) -> Result<Option<String>, Failure> {
let r = if let Some(p) = args.opt("--save-as") {
@ -436,7 +451,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
if args.opt("--bridge").is_some() {
return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge");
}
let mut s = effectcraft_host::session();
let mut s = session(args)?;
match &args.project {
Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?,
None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?,
@ -549,6 +564,9 @@ fn bench_cmd(args: &Args) -> Result<(), Failure> {
if let Some(c) = args.opt("--comp") {
s.execute("comp.open", json!({"comp": c})).map_err(|e| Failure::Error(e.to_string()))?;
}
if args.flag("--gpu") {
return bench_gpu(&s, args);
}
let cid = s.active_comp_id().ok_or_else(|| Failure::Error("no composition".into()))?;
let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0));
let opts = RenderOpts { scale: args.num("--scale")?.unwrap_or(1.0), ..Default::default() };
@ -664,3 +682,70 @@ fn bench_play(s: &Session, cid: ItemId, t: Tick, opts: RenderOpts, n: usize) {
let st = cache.stats();
eprintln!(" layer cache: {} hits, {} misses, {} entries, {:.1} MB", st.hits, st.misses, st.entries, st.bytes as f64 / 1e6);
}
/// Median wall time (ms) of `n` runs of `f` after one warm-up run.
fn time_ms(n: usize, mut f: impl FnMut()) -> f64 {
f();
let mut v: Vec<f64> = (0..n)
.map(|_| {
let t0 = std::time::Instant::now();
f();
t0.elapsed().as_secs_f64() * 1e3
})
.collect();
median(&mut v)
}
/// `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.
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;
let comps: Vec<ItemId> = match args.opt("--comp") {
Some(_) => s.active_comp_id().into_iter().collect(),
None => s.project.comps().map(|(id, _)| *id).collect(),
};
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");
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 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() });
r.expr = s.expr.as_deref();
r.cache = cache;
r.accel = Some(&gpu);
r.comp_frame(cid, t)
};
let cpu_cold = time_ms(n, || {
std::hint::black_box(mk(Backend::Cpu, None));
});
let gpu_cold = time_ms(n, || {
std::hint::black_box(mk(Backend::Gpu, None));
});
let cache = LayerCache::default();
let cpu_warm = time_ms(n, || {
std::hint::black_box(mk(Backend::Cpu, Some(&cache)));
});
let gcache = LayerCache::default();
let gpu_warm = time_ms(n, || {
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() });
r.expr = s.expr.as_deref();
r.cache = Some(&gcache);
r.accel = Some(&gpu);
std::hint::black_box(gpu.render_display(&r, cid, t));
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}");
}
}
Ok(())
}

View File

@ -119,6 +119,12 @@ impl Curve {
h00 * self.ys[k] + h10 * h * self.ms[k] + h01 * self.ys[k + 1] + h11 * h * self.ms[k + 1]
}
/// Control points (x, y), Hermite tangents and the 1025-entry table over 0..1 that
/// [`Curve::eval`] interpolates (the GPU path uploads them).
pub fn tables(&self) -> (&[f32], &[f32], &[f32], &[f32]) {
(&self.xs, &self.ys, &self.ms, &self.lut)
}
pub fn eval(&self, x: f32) -> f32 {
if !(0.0..=1.0).contains(&x) {
return self.eval_exact(x);

View File

@ -353,6 +353,11 @@ pub fn registry() -> &'static [EffectSpec] {
v.extend(paint::specs());
v.extend(puppet::specs());
v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name)));
for s in v.iter_mut() {
if GPU_EFFECTS.contains(&s.id) {
s.gpu = true;
}
}
v
})
}
@ -411,6 +416,28 @@ pub fn instantiate(spec: &EffectSpec, ids: &mut Ids, instance_name: &str, layer_
g
}
/// Effects the GPU compositor (`effectcraft-gpu`) implements with the CPU effect's semantics
/// (Mercury GPU Acceleration; the Effects & Presets GPU badge). Expression controls are
/// pass-throughs and count as GPU effects too.
pub const GPU_EFFECTS: &[&str] = &[
"ec.blur.gaussian",
"ec.blur.fastbox",
"ec.blur.directional",
"ec.stylize.glow",
"ec.color.levels",
"ec.color.curves",
"ec.color.huesaturation",
"ec.color.tint",
"ec.generate.fill",
"ec.generate.gradientramp",
"ec.noise.fractal",
"ec.perspective.dropshadow",
"ec.color.brightnesscontrast",
"ec.color.exposure",
"ec.channel.invert",
"ec.distort.transform",
];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
/// parameters). The renderer's layer cache folds the layer time into the key for these.
/// The `time_dependence_is_declared` test checks the list against every registered effect.

View File

@ -89,7 +89,7 @@ fn save_frame(s: &mut Session, p: &Value) -> Result<Value> {
let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time());
let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0);
let bg = comp.background;
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, ..Default::default() });
let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
let rgba = img.to_rgba8_over(bg);
let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?;
s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;

View File

@ -152,6 +152,17 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
),
};
}
if let Some(r) = p.get("renderer").or_else(|| p.get("gpuAcceleration")) {
proj.settings.gpu_acceleration = match r {
Value::Bool(b) => *b,
Value::String(s) => match effectcraft_render::Backend::parse(s) {
Some(effectcraft_render::Backend::Cpu) => false,
Some(_) => true,
None => return Err(bad("file.projectSettings", format!("renderer: gpu|software, not `{s}`"))),
},
_ => return Err(bad("file.projectSettings", "renderer: gpu|software")),
};
}
if let Some(t) = str_p(p, "timeDisplay") {
proj.settings.time_display =
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
@ -161,6 +172,41 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
Ok(serde_json::to_value(&s.project.settings).unwrap_or_default())
}
/// Video Rendering and Effects: report or set the renderer (Mercury GPU Acceleration when a GPU
/// adapter exists, else Mercury Software Only).
fn render_backend(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(b) = p.get("backend").or_else(|| p.get("renderer")) {
let gpu = match b {
Value::Bool(b) => *b,
Value::String(v) => match effectcraft_render::Backend::parse(v) {
Some(effectcraft_render::Backend::Cpu) => false,
Some(_) => true,
None => return Err(bad("render.backend", format!("backend: gpu|cpu, not `{v}`"))),
},
_ => return Err(bad("render.backend", "backend: gpu|cpu")),
};
if gpu != s.project.settings.gpu_acceleration {
s.edit("Project Settings", None, |proj, _| {
proj.settings.gpu_acceleration = gpu;
Ok(())
})?;
}
}
Ok(backend_status(s))
}
/// The renderer setting and what renders with it.
pub fn backend_status(s: &Session) -> Value {
let adapter = s.accel.as_ref().map(|a| a.name());
let gpu = s.project.settings.gpu_acceleration;
json!({
"renderer": if gpu { "Mercury GPU Acceleration" } else { "Mercury Software Only" },
"gpuAcceleration": gpu,
"adapter": adapter,
"active": if gpu && adapter.is_some() { "gpu" } else { "cpu" },
})
}
fn cycle_depth(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Project Bit Depth", None, |proj, _| {
proj.settings.bit_depth = proj.settings.bit_depth.next();
@ -184,10 +230,11 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}",
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, renderer?: gpu|software, timeDisplay?: timecode|frames}",
always,
project_settings
),
cmd!("file.cycleBitDepth", "Cycle Project Bit Depth", [], None, "{}", always, cycle_depth),
cmd!("render.backend", "Video Rendering and Effects", [], None, "{backend?: gpu|cpu}", always, render_backend),
]
}

View File

@ -47,6 +47,7 @@ fn summary(s: &mut Session, _: &Value) -> Result<Value> {
"path": s.path,
"dirty": s.is_dirty(),
"bitDepth": s.project.settings.bit_depth.label(),
"renderer": super::file::backend_status(s),
"items": items,
"activeComp": s.state.active_comp.map(|c| c.0),
"time": s.time().seconds(),

View File

@ -495,7 +495,8 @@ fn save_current_preview(s: &mut Session, p: &Value) -> Result<Value> {
let mut item = RenderQueueItem::new(0, cid);
item.output = OutputModule::for_format(format);
item.output.output = path.clone();
let job = crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), item: &item, path: &path };
let job =
crate::ExportJob { project: &s.project, footage: s.footage.as_ref(), expr: s.expr.as_deref(), accel: s.accel.as_deref(), item: &item, path: &path };
let r = exporter.export(&job, &mut |_, _| true).map_err(EngineError::Other)?;
s.toast(format!("Saved preview to {}", r.path));
Ok(serde_json::to_value(&r).unwrap_or_default())

View File

@ -225,6 +225,10 @@ pub struct Session {
pub services: Arc<dyn Services>,
pub footage: Arc<dyn FootageSource>,
pub expr: Option<Arc<dyn ExprHost>>,
/// GPU compositor (Mercury GPU Acceleration), when the frontend has one: used by renders
/// that ask for [`effectcraft_render::Backend::Gpu`]/`Auto`, and by Render Queue exports
/// when the project's renderer is the GPU.
pub accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
/// Expression syntax checker (set by the host that links the expression engine).
pub expr_check: Option<fn(&str) -> std::result::Result<(), String>>,
pub importer: Option<Arc<dyn Importer>>,
@ -268,6 +272,7 @@ impl Default for Session {
services: Arc::new(FsServices),
footage: Arc::new(NoFootage),
expr: None,
accel: None,
expr_check: None,
importer: None,
exporter: None,
@ -464,6 +469,7 @@ impl Session {
let mut r = Renderer::new(&self.project, self.footage.as_ref(), opts);
r.expr = self.expr.as_deref();
r.cache = Some(&self.layer_cache);
r.accel = self.accel.as_deref();
r.comp_frame(comp, t)
}
@ -482,7 +488,7 @@ impl Session {
let c = self.project.comp(comp).ok_or(EngineError::NoComp)?;
let long = c.width.max(c.height).max(1) as f64;
let scale = if max_side == 0 { 1.0 } else { (max_side as f64 / long).min(1.0) };
let img = self.render(comp, t, RenderOpts { scale, ..Default::default() });
let img = self.render(comp, t, RenderOpts { scale, backend: effectcraft_render::Backend::Auto, ..Default::default() });
Ok((img.width, img.height, img.to_rgba8_over(c.background)))
}

View File

@ -23,6 +23,8 @@ pub struct ExportJob<'a> {
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
pub item: &'a RenderQueueItem,
/// GPU compositor to render with when the project's renderer is Mercury GPU Acceleration.
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
/// Resolved output path (templates expanded except the `#` frame-number run).
pub path: &'a str,
}
@ -120,6 +122,7 @@ struct Work {
project: Arc<Project>,
footage: Arc<dyn FootageSource>,
expr: Option<Arc<dyn ExprHost>>,
accel: Option<Arc<dyn effectcraft_render::Accelerator>>,
exporter: Arc<dyn Exporter>,
/// Per queue item: one (item with that output module, resolved path) per output module.
items: Vec<Vec<(RenderQueueItem, String)>>,
@ -155,7 +158,7 @@ fn run_work(w: Work, shared: &JobShared) {
// first module's file is the item's output.
let mut r: Result<ExportResult, String> = Err("no output module".into());
for (mi, (mitem, path)) in modules.iter().enumerate() {
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), item: mitem, path };
let job = ExportJob { project: &w.project, footage: w.footage.as_ref(), expr: w.expr.as_deref(), accel: w.accel.as_deref(), item: mitem, path };
let rr = w.exporter.export(&job, &mut |done, total| {
let mut s = lock(&shared.state);
s.done = done;
@ -288,7 +291,7 @@ impl Session {
return Err("nothing is queued: add a composition (Composition ▸ Add to Render Queue) and tick Render".into());
}
let ids: Vec<u64> = items.iter().filter_map(|m| m.first().map(|(i, _)| i.id)).collect();
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), exporter, items };
let work = Work { project: self.project.clone(), footage: self.footage.clone(), expr: self.expr.clone(), accel: self.accel.clone(), exporter, items };
let shared = Arc::new(JobShared::default());
if wait {
run_work(work, &shared);

View File

@ -56,6 +56,8 @@ pub struct Job<'a> {
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
/// GPU compositor: used when the project's renderer is Mercury GPU Acceleration.
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
pub comp: ItemId,
pub settings: &'a RenderSettings,
pub output: &'a OutputModule,
@ -172,9 +174,11 @@ pub(crate) fn render_frame(job: &Job, comp: &Comp, i: u64) -> Image {
draft: job.settings.quality == RenderQuality::Draft,
// Output renders always look through the comp's active camera.
view: None,
backend: effectcraft_render::Backend::Auto,
};
let mut r = Renderer::new(job.project, job.footage, opts);
r.expr = job.expr;
r.accel = job.accel;
r.comp_frame(job.comp, t)
}

View File

@ -55,7 +55,7 @@ fn settings() -> RenderSettings {
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = settings();
let path = path.to_string_lossy().to_string();
let job = Job { project: p, footage, expr: None, comp: cid, settings: &s, output: om, path: &path, sink: None };
let job = Job { project: p, footage, expr: None, accel: None, comp: cid, settings: &s, output: om, path: &path, sink: None };
let mut last = Progress::default();
let mut calls = 0;
let r = export(&job, &mut |pr| {
@ -167,7 +167,7 @@ fn h264_odd_size_is_cropped_even() {
let comp = p.comp(cid).expect("comp");
assert_eq!(s.output_size(comp).0 % 2, 1);
let path = d.join("odd.mp4").to_string_lossy().to_string();
let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.width % 2, r.height % 2), (0, 0));
}
@ -287,7 +287,7 @@ fn cancel_stops_early() {
let om = OutputModule::for_format(OutputFormat::PngSequence);
let s = settings();
let path = d.join("c_[#####].png").to_string_lossy().to_string();
let job = Job { project: &p, footage: &NoFootage, expr: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
let job = Job { project: &p, footage: &NoFootage, expr: None, accel: None, comp: cid, settings: &s, output: &om, path: &path, sink: None };
let r = export(&job, &mut |_| false);
assert!(matches!(r, Err(effectcraft_export::ExportError::Cancelled)));
let written = std::fs::read_dir(&d).map(|r| r.count()).unwrap_or(0);

29
crates/gpu/Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "effectcraft-gpu"
description = "The GPU compositor (wgpu compute): layer transforms, masks/mattes, the 38 blend modes, motion blur, bit-depth quantisation and GPU effects, with the CPU renderer as the reference"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-geom = { workspace = true }
effectcraft-color = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-effects = { workspace = true }
effectcraft-render = { workspace = true }
effectcraft-keyframe = { workspace = true }
wgpu = { workspace = true }
bytemuck = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
pollster = { workspace = true }
[dev-dependencies]
effectcraft-text = { workspace = true }

528
crates/gpu/src/context.rs Normal file
View File

@ -0,0 +1,528 @@
//! Device, pipelines, textures, uploads and readback.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, Weak};
use effectcraft_effects::Buf;
use effectcraft_raster::Image;
use wgpu::util::DeviceExt;
/// Compute entry points in `kernels.wgsl` (one pipeline each).
const ENTRIES: &[&str] = &[
"warp_blend",
"blend_full",
"matte",
"preserve",
"knockout",
"channel_mix",
"quantize",
"convert",
"half",
"box_h",
"box_v",
"directional",
"glow_bright",
"glow_combine",
"shadow_make",
"shadow_combine",
"pointwise",
];
/// 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;
/// Uploaded layer buffers kept on the GPU (bytes).
const UPLOAD_BUDGET: usize = 1536 << 20;
/// A GPU image: premultiplied RGBA f32 in a texture (cheap to clone; immutable once written).
#[derive(Clone, Debug)]
pub struct GpuImage {
pub texture: wgpu::Texture,
pub width: u32,
pub height: u32,
}
/// Uniform parameter block shared by every kernel (see `shaders/common.wgsl`).
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Params {
pub u: [[u32; 4]; 4],
pub f: [[f32; 4]; 12],
}
impl Params {
fn bytes(&self) -> Vec<u8> {
let mut v = Vec::with_capacity(256);
for r in &self.u {
for x in r {
v.extend_from_slice(&x.to_le_bytes());
}
}
for r in &self.f {
for x in r {
v.extend_from_slice(&x.to_le_bytes());
}
}
v
}
}
struct Upload {
buf: Weak<Buf>,
img: GpuImage,
bytes: usize,
last_use: u64,
}
#[derive(Default)]
struct Uploads {
map: HashMap<usize, Upload>,
bytes: usize,
clock: u64,
}
/// The device and everything compiled for it.
pub struct GpuContext {
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub(crate) name: String,
pub(crate) max_dim: u32,
bgl: wgpu::BindGroupLayout,
pipelines: HashMap<&'static str, wgpu::ComputePipeline>,
display_bgl: wgpu::BindGroupLayout,
display: wgpu::ComputePipeline,
dummy_tex: wgpu::TextureView,
dummy_buf: wgpu::Buffer,
uploads: Mutex<Uploads>,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
fn storage_tex_entry(binding: u32, format: wgpu::TextureFormat) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format, view_dimension: wgpu::TextureViewDimension::D2 },
count: None,
}
}
impl GpuContext {
/// Build on an existing device (the desktop app shares egui-wgpu's). `Err` when the
/// adapter cannot run the compositor (no compute shaders, e.g. WebGL2; no float storage
/// textures).
pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result<GpuContext, String> {
let info = adapter.get_info();
if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) {
return Err(format!("{} ({:?}): no compute shaders", info.name, info.backend));
}
for f in [FORMAT, wgpu::TextureFormat::Rgba8Unorm] {
if !adapter.get_texture_format_features(f).allowed_usages.contains(wgpu::TextureUsages::STORAGE_BINDING) {
return Err(format!("{} ({:?}): {f:?} storage textures unsupported", info.name, info.backend));
}
}
let name = format!("{} ({:?})", info.name, info.backend);
let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.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 {
label: Some("effectcraft kernels"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
},
tex_entry(1),
tex_entry(2),
storage_tex_entry(3, FORMAT),
wgpu::BindGroupLayoutEntry {
binding: 4,
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 layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effectcraft kernels"),
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let pipelines = ENTRIES
.iter()
.map(|e| {
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(e),
layout: Some(&layout),
module: &module,
entry_point: Some(e),
compilation_options: Default::default(),
cache: None,
});
(*e, p)
})
.collect();
let dmodule = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("effectcraft display"),
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/display.wgsl").into()),
});
let display_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("effectcraft display"),
entries: &[tex_entry(1), storage_tex_entry(3, wgpu::TextureFormat::Rgba8Unorm)],
});
let dlayout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effectcraft display"),
bind_group_layouts: &[Some(&display_bgl)],
immediate_size: 0,
});
let display = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("display"),
layout: Some(&dlayout),
module: &dmodule,
entry_point: Some("display"),
compilation_options: Default::default(),
cache: None,
});
let dummy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("dummy"),
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let dummy_buf =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("dummy"), contents: &[0u8; 16], usage: wgpu::BufferUsages::STORAGE });
let max_dim = device.limits().max_texture_dimension_2d;
Ok(GpuContext {
device,
queue,
name,
max_dim,
bgl,
pipelines,
display_bgl,
display,
dummy_tex: dummy.create_view(&Default::default()),
dummy_buf,
uploads: Mutex::new(Uploads::default()),
})
}
/// A device of its own on the best adapter (CLI, tests, benchmarks). `None` when no
/// adapter qualifies.
#[cfg(not(target_arch = "wasm32"))]
pub fn headless() -> Option<GpuContext> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
)
.ok()?;
let limits = adapter.limits();
let required_limits = wgpu::Limits {
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
max_buffer_size: limits.max_buffer_size,
..wgpu::Limits::default()
};
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("effectcraft gpu"), required_limits, ..Default::default() }))
.ok()?;
device.on_uncaptured_error(Arc::new(|e| log::error!("wgpu: {e}")));
GpuContext::new(&adapter, device, queue).map_err(|e| log::info!("gpu: {e}")).ok()
}
/// Readback (GPU → CPU) waits for the device; impossible on the browser's main thread.
pub fn can_readback(&self) -> bool {
cfg!(not(target_arch = "wasm32"))
}
pub(crate) fn fits(&self, w: u32, h: u32) -> bool {
w > 0 && h > 0 && w <= self.max_dim && h <= self.max_dim
}
/// A new (zeroed) working texture.
pub(crate) fn image(&self, w: u32, h: u32) -> GpuImage {
let texture = self.device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d { width: w.max(1), height: h.max(1), depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
GpuImage { texture, width: w, height: h }
}
/// Upload a CPU image (`None` when it does not fit the device).
pub fn upload_image(&self, img: &Image) -> Option<GpuImage> {
if !self.fits(img.width, img.height) {
return None;
}
let g = self.image(img.width, img.height);
self.queue.write_texture(
wgpu::TexelCopyTextureInfo { texture: &g.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
bytemuck::cast_slice(&img.data),
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(img.width * 16), rows_per_image: Some(img.height) },
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
Some(g)
}
/// Upload a (layer cache) buffer, reusing the texture while the same `Arc` is alive: static
/// layers upload once, not every frame.
pub(crate) fn upload_buf(&self, buf: &Arc<Buf>) -> Option<GpuImage> {
let key = Arc::as_ptr(buf) as usize;
if let Ok(mut u) = self.uploads.lock() {
u.clock += 1;
let now = u.clock;
if let Some(e) = u.map.get_mut(&key)
&& e.buf.upgrade().is_some_and(|b| Arc::ptr_eq(&b, buf))
{
e.last_use = now;
return Some(e.img.clone());
}
}
let img = self.upload_image(&buf.img)?;
if let Ok(mut u) = self.uploads.lock() {
let bytes = buf.img.data.len() * 16;
let now = u.clock;
if let Some(old) = u.map.insert(key, Upload { buf: Arc::downgrade(buf), img: img.clone(), bytes, last_use: now }) {
u.bytes -= old.bytes;
}
u.bytes += bytes;
// Drop textures of freed buffers, then the least recently used over budget.
if u.bytes > UPLOAD_BUDGET {
let dead: Vec<usize> = u.map.iter().filter(|(_, e)| e.buf.strong_count() == 0).map(|(k, _)| *k).collect();
for k in dead {
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
}
}
while u.bytes > UPLOAD_BUDGET {
let Some(k) = u.map.iter().min_by_key(|(_, e)| e.last_use).map(|(k, _)| *k) else { break };
if let Some(e) = u.map.remove(&k) {
u.bytes -= e.bytes;
}
}
}
Some(img)
}
/// Forget uploaded layer textures.
pub fn clear_uploads(&self) {
if let Ok(mut u) = self.uploads.lock() {
u.map.clear();
u.bytes = 0;
}
}
}
/// Records GPU work for one render (a command encoder submitted at readback or the end).
pub(crate) struct Enc<'g> {
pub g: &'g GpuContext,
enc: Option<wgpu::CommandEncoder>,
pending: usize,
}
impl<'g> Enc<'g> {
pub fn new(g: &'g GpuContext) -> Enc<'g> {
Enc { g, enc: None, pending: 0 }
}
fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
let g = self.g;
self.enc.get_or_insert_with(|| g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("effectcraft") }))
}
/// A new zeroed image.
pub fn image(&self, w: u32, h: u32) -> GpuImage {
self.g.image(w, h)
}
/// Run kernel `entry` writing `out` (dispatched over `groups` workgroups).
pub fn dispatch(
&mut self,
entry: &str,
p: &Params,
src: &GpuImage,
aux: Option<&GpuImage>,
out: &GpuImage,
data: Option<&wgpu::Buffer>,
groups: (u32, u32),
) {
let g = self.g;
let Some(pipe) = g.pipelines.get(entry) else {
log::error!("gpu: no kernel {entry}");
return;
};
let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: &p.bytes(), usage: wgpu::BufferUsages::UNIFORM });
let sv = src.texture.create_view(&Default::default());
let av = aux.map(|a| a.texture.create_view(&Default::default()));
let ov = out.texture.create_view(&Default::default());
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &g.bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sv) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(av.as_ref().unwrap_or(&g.dummy_tex)) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&ov) },
wgpu::BindGroupEntry { binding: 4, resource: data.unwrap_or(&g.dummy_buf).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, &[]);
pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1);
}
self.pending += 1;
// Keep command buffers (and the transient textures they hold) bounded.
if self.pending >= 256 {
self.submit();
}
}
/// Per-pixel kernel over `out`'s size (16×16 workgroups).
pub fn pixels(&mut self, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, out: &GpuImage, data: Option<&wgpu::Buffer>) {
let groups = (out.width.div_ceil(16), out.height.div_ceil(16));
self.dispatch(entry, p, src, aux, out, data, groups);
}
/// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad).
pub fn copy_into(&mut self, src: &GpuImage, dst: &GpuImage, x: u32, y: u32) {
let enc = self.encoder();
enc.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo { texture: &src.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyTextureInfo { texture: &dst.texture, mip_level: 0, origin: wgpu::Origin3d { x, y, z: 0 }, aspect: wgpu::TextureAspect::All },
wgpu::Extent3d { width: src.width, height: src.height, depth_or_array_layers: 1 },
);
}
/// Submit recorded work.
pub fn submit(&mut self) {
if let Some(enc) = self.enc.take() {
self.g.queue.submit([enc.finish()]);
}
self.pending = 0;
}
/// Read an image back to the CPU (submits and waits). `None` where readback is impossible.
pub fn download(&mut self, img: &GpuImage) -> Option<Image> {
let bytes = self.read_texture(&img.texture, img.width, img.height, 16)?;
let mut out = Image::new(img.width, img.height);
for (p, c) in out.data.iter_mut().zip(bytes.chunks_exact(16)) {
for (k, v) in p.iter_mut().enumerate() {
*v = f32::from_le_bytes([c[4 * k], c[4 * k + 1], c[4 * k + 2], c[4 * k + 3]]);
}
}
Some(out)
}
/// Tightly packed texel bytes of a texture (`bpp` bytes per pixel).
pub fn read_texture(&mut self, texture: &wgpu::Texture, w: u32, h: u32, bpp: u32) -> Option<Vec<u8>> {
if !self.g.can_readback() {
return None;
}
let row = w * bpp;
let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let size = padded as u64 * h as u64;
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.encoder().copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { 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(padded), rows_per_image: Some(h) } },
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
self.submit();
let (tx, rx) = std::sync::mpsc::channel();
buf.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = tx.send(r);
});
if let Err(e) = self.g.device.poll(wgpu::PollType::wait_indefinitely()) {
log::error!("gpu: poll: {e}");
return None;
}
rx.recv().ok()?.ok()?;
let view = buf.get_mapped_range(..).ok()?;
let mut out = Vec::with_capacity((row * h) as usize);
for y in 0..h as usize {
out.extend_from_slice(&view[y * padded as usize..y * padded as usize + row as usize]);
}
drop(view);
buf.unmap();
Some(out)
}
/// A data buffer for kernels that read tables (curves).
pub fn data(&self, v: &[f32]) -> wgpu::Buffer {
let mut bytes = Vec::with_capacity(v.len().max(4) * 4);
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
while bytes.len() < 16 {
bytes.push(0);
}
self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE })
}
/// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format).
pub fn display(&mut self, img: &GpuImage) -> wgpu::Texture {
let g = self.g;
let tex = g.device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewer frame"),
size: wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let sv = img.texture.create_view(&Default::default());
let ov = tex.create_view(&Default::default());
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &g.display_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sv) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&ov) },
],
});
let enc = self.encoder();
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("display"), timestamp_writes: None });
pass.set_pipeline(&g.display);
pass.set_bind_group(0, &bg, &[]);
pass.dispatch_workgroups(img.width.div_ceil(16), img.height.div_ceil(16), 1);
}
tex
}
}

347
crates/gpu/src/effects.rs Normal file
View File

@ -0,0 +1,347 @@
//! GPU effects: the same steps as the CPU effects in `effectcraft-effects` (padding, blur
//! radii, parameter conversions), with the pixel loops as compute kernels.
use effectcraft_effects::{Buf, EffectCtx};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_raster::Sampling;
use effectcraft_render::FxStep;
use crate::context::{Enc, GpuImage, Params};
use crate::ops;
/// Effects with a GPU implementation (must equal `effectcraft_effects::GPU_EFFECTS`), plus the
/// expression controls (pass-throughs).
pub fn supports(id: &str) -> bool {
effectcraft_effects::GPU_EFFECTS.contains(&id) || id.starts_with("ec.control.")
}
/// A layer buffer on the GPU (see [`Buf`]).
struct GBuf {
img: GpuImage,
offset: [f64; 2],
scale: f64,
}
impl GBuf {
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<()> {
if pad == 0 {
return Some(());
}
let (w, h) = (self.img.width + 2 * pad, self.img.height + 2 * pad);
if !e.g.fits(w, h) {
return None;
}
let out = e.image(w, h);
e.copy_into(&self.img, &out, pad, pad);
self.img = out;
self.offset[0] += pad as f64;
self.offset[1] += pad as f64;
Some(())
}
}
/// Run a chain of GPU effects (one upload, one readback), quantising after each when `levels`
/// is set. `None` when anything cannot run here.
pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
if !e.g.can_readback() || buf.img.is_empty() {
return None;
}
let mut b = GBuf { img: e.g.upload_image(&buf.img)?, offset: buf.offset, scale: buf.scale };
for step in chain {
b = apply(e, step.spec.id, &step.ctx, b)?;
if let Some(l) = levels {
b.img = ops::quantize(e, &b.img, l);
}
}
let img = e.download(&b.img)?;
Some(Buf { img, offset: b.offset, scale: b.scale })
}
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.blur.gaussian" => gaussian(e, ctx, b),
"ec.blur.fastbox" => box_blur(e, ctx, b),
"ec.blur.directional" => directional(e, ctx, b),
"ec.stylize.glow" => glow(e, ctx, b),
"ec.perspective.dropshadow" => drop_shadow(e, ctx, b),
"ec.distort.transform" => transform(e, ctx, b),
"ec.color.curves" => curves(e, ctx, b),
_ if id.starts_with("ec.control.") => Some(b),
_ => pointwise(e, id, ctx, b),
}
}
// ---------------------------------------------------------------- blurs
/// raster::blur::box_radii.
fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
if sigma <= 0.0 {
return vec![0; n];
}
let w_ideal = (12.0 * sigma * sigma / n as f64 + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - (n as i64 * wl * wl) as f64 - 4.0 * n as f64 * wl as f64 - 3.0 * n as f64) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
(0..n as i64).map(|i| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).collect()
}
/// 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 {
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 {
let block = if vertical { (4 * r).clamp(32, 256) } else { 256 } as u32;
let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) };
let mut p = Params::default();
p.u[0] = [r as u32, repeat as u32, block, 0];
let out = e.image(cur.width, cur.height);
e.dispatch(if vertical { "box_v" } else { "box_h" }, &p, &cur, None, &out, None, (lines.div_ceil(64), n.div_ceil(block)));
cur = out;
}
cur
}
/// raster::gaussian_blur (3 box passes per axis).
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)
}
fn dims_xy(dim: u32) -> (f64, f64) {
match dim {
1 => (1.0, 0.0),
2 => (0.0, 1.0),
_ => (1.0, 1.0),
}
}
fn gaussian(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let s = ctx.params.f("blurriness").max(0.0) * 0.5 * b.scale;
if s <= 0.0 {
return Some(b);
}
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad(e, (s * 3.0).ceil() as u32)?;
}
b.img = gaussian_blur(e, &b.img, s * kx, s * ky, repeat || ctx.adjustment);
Some(b)
}
fn box_blur(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let r = (ctx.params.f("radius") * b.scale).round().max(0.0) as usize;
if r == 0 {
return Some(b);
}
let it = ctx.params.f("iterations").round().clamp(1.0, 50.0) as usize;
let (kx, ky) = dims_xy(ctx.params.e("dimensions"));
let repeat = ctx.params.b("repeatEdge");
if !repeat && !ctx.adjustment {
b.pad(e, (r * it) as u32 + 1)?;
}
b.img = box_passes(e, &b.img, &vec![r * kx as usize; it], &vec![r * ky as usize; it], repeat || ctx.adjustment);
Some(b)
}
fn directional(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let len = ctx.params.f("length") * b.scale;
if len < 0.5 {
return Some(b);
}
if !ctx.adjustment {
b.pad(e, len.ceil() as u32 + 1)?;
}
let a = ctx.params.f("direction").to_radians();
let n = (len.ceil() as usize * 2 + 1).clamp(3, 257);
let mut p = Params::default();
p.f[0] = [a.sin() as f32, -a.cos() as f32, len as f32, n as f32];
let out = e.image(b.img.width, b.img.height);
e.pixels("directional", &p, &b.img, None, &out, None);
b.img = out;
Some(b)
}
fn glow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let thr = ctx.params.f("threshold") as f32 / 100.0;
let radius = ctx.params.f("radius") * b.scale;
let intensity = ctx.params.f("intensity") as f32;
let use_colors = ctx.params.e("colors") == 1;
let ca = ctx.params.color("colorA");
let cb = ctx.params.color("colorB");
if !ctx.adjustment {
b.pad(e, (radius * 1.5).ceil() as u32 + 2)?;
}
let mut p = Params::default();
p.u[0][0] = use_colors as u32;
p.f[0][0] = thr;
p.f[1] = ca;
p.f[2] = cb;
let bright = e.image(b.img.width, b.img.height);
e.pixels("glow_bright", &p, &b.img, None, &bright, None);
let s = (radius / 2.0).max(0.5);
let blurred = gaussian_blur(e, &bright, s, s, false);
let mut p = Params::default();
p.u[0][0] = (ctx.params.e("operation") == 1) as u32;
p.f[0][0] = intensity;
let out = e.image(b.img.width, b.img.height);
e.pixels("glow_combine", &p, &b.img, Some(&blurred), &out, None);
b.img = out;
Some(b)
}
fn drop_shadow(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let color = ctx.params.color("color");
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let dir = ctx.params.f("direction").to_radians();
let dist = ctx.params.f("distance") * b.scale;
let soft = ctx.params.f("softness") * b.scale;
let only = ctx.params.b("shadowOnly");
let pad = (dist + soft * 1.5).ceil() as u32 + 2;
if !ctx.adjustment {
b.pad(e, pad)?;
}
let (dx, dy) = (dir.sin() * dist, -dir.cos() * dist);
let mut p = Params::default();
p.f[0] = color;
p.f[1] = [opacity, dx as f32, dy as f32, 0.0];
let mut sh = e.image(b.img.width, b.img.height);
e.pixels("shadow_make", &p, &b.img, None, &sh, None);
if soft > 0.0 {
sh = gaussian_blur(e, &sh, soft / 2.0, soft / 2.0, false);
}
if !only {
let out = e.image(b.img.width, b.img.height);
e.pixels("shadow_combine", &Params::default(), &sh, Some(&b.img), &out, None);
sh = out;
}
b.img = sh;
Some(b)
}
fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let anchor = b.to_px(ctx.params.v2("anchor"));
let pos = b.to_px(ctx.params.v2("position"));
let sh = ctx.params.f("scaleHeight");
let sw = if ctx.params.b("uniform") { sh } else { ctx.params.f("scaleWidth") };
let opacity = ctx.params.f("opacity") as f32 / 100.0;
let m = Mat3::translate(vec2(pos.0, pos.1))
* Mat3::rotate_deg(ctx.params.f("rotation"))
* Mat3::skew_deg(ctx.params.f("skew"), ctx.params.f("skewAxis"))
* Mat3::scale(vec2(sw / 100.0, sh / 100.0))
* Mat3::translate(vec2(-anchor.0, -anchor.1));
let empty = e.image(b.img.width, b.img.height);
b.img = ops::warp(e, &empty, &b.img, &m, Sampling::Bilinear, effectcraft_color::BlendMode::Normal, opacity, 0, None);
Some(b)
}
// ---------------------------------------------------------------- per-pixel
fn curves(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let parsed = ["rgb", "red", "green", "blue", "alpha"].map(|id| effectcraft_effects::Curve::parse(ctx.params.s(id)));
if parsed.iter().all(Option::is_none) {
return Some(b);
}
let mut data = vec![];
let mut offs = [-1.0f32; 5];
for (i, c) in parsed.iter().enumerate() {
let Some(c) = c else { continue };
let (xs, ys, ms, lut) = c.tables();
offs[i] = data.len() as f32;
data.push(xs.len() as f32);
data.extend_from_slice(xs);
data.extend_from_slice(ys);
data.extend_from_slice(ms);
data.extend_from_slice(lut);
}
let buf = e.data(&data);
let mut p = Params::default();
p.u[0][0] = 8;
p.f[0] = [offs[0], offs[1], offs[2], offs[3]];
p.f[1][0] = offs[4];
let out = e.image(b.img.width, b.img.height);
e.pixels("pointwise", &p, &b.img, None, &out, Some(&buf));
Some(GBuf { img: out, ..b })
}
fn rgb(c: [f32; 4]) -> [f32; 4] {
[c[0], c[1], c[2], 0.0]
}
fn pointwise(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let f = |k: &str| ctx.params.f(k);
let mut p = Params::default();
match id {
"ec.color.tint" => {
p.u[0][0] = 1;
p.f[0] = rgb(ctx.params.color("black"));
p.f[1] = rgb(ctx.params.color("white"));
p.f[2][0] = f("amount") as f32 / 100.0;
}
"ec.color.brightnesscontrast" => {
let br = f("brightness") as f32 / 100.0;
let ct = f("contrast") as f32 / 100.0;
let k = if ct >= 0.0 { 1.0 / (1.0 - ct * 0.99) } else { 1.0 + ct };
p.u[0][0] = 2;
p.f[0] = [br, k, 0.0, 0.0];
}
"ec.color.huesaturation" => {
p.u[0] = [3, ctx.params.b("colorize") as u32, 0, 0];
p.f[0] = [f("hue") as f32 / 360.0, f("saturation") as f32 / 100.0, f("lightness") as f32 / 100.0, 0.0];
p.f[1] = [f("colorizeHue") as f32 / 360.0, f("colorizeSaturation") as f32 / 100.0, f("colorizeLightness") as f32 / 100.0, 0.0];
}
"ec.color.levels" => {
p.u[0] = [4, !ctx.params.b("noClip") as u32, 0, 0];
p.f[0] = [f("inBlack") as f32, f("inWhite") as f32, f("gamma").max(0.01) as f32, f("outBlack") as f32];
p.f[1][0] = f("outWhite") as f32;
}
"ec.color.exposure" => {
p.u[0][0] = 5;
p.f[0] = [2f32.powf(f("exposure") as f32), f("offset") as f32, f("gamma").max(0.01) as f32, 0.0];
}
"ec.channel.invert" => {
p.u[0] = [6, ctx.params.e("channel"), 0, 0];
p.f[0][0] = 1.0 - f("blend") as f32 / 100.0;
}
"ec.generate.fill" => {
p.u[0] = [7, ctx.params.b("invert") as u32, 0, 0];
p.f[0] = ctx.params.color("color");
p.f[1][0] = f("opacity") as f32 / 100.0;
}
"ec.generate.gradientramp" => {
let s = b.to_px(ctx.params.v2("start"));
let en = b.to_px(ctx.params.v2("end"));
let (dx, dy) = (en.0 - s.0, en.1 - s.1);
let len2 = (dx * dx + dy * dy).max(1e-9);
p.u[0] = [9, (ctx.params.e("shape") == 1) as u32, ctx.seed, 0];
p.f[0] = [s.0 as f32, s.1 as f32, en.0 as f32, en.1 as f32];
p.f[1] = rgb(ctx.params.color("startColor"));
p.f[2] = rgb(ctx.params.color("endColor"));
p.f[3] = [len2 as f32, f("scatter") as f32 / 512.0, f("blend") as f32 / 100.0, 0.0];
}
"ec.noise.fractal" => {
let octaves = f("complexity").clamp(1.0, 20.0);
let (sr, cr) = (f("rotation") as f32).to_radians().sin_cos();
let (ox, oy) = b.to_px(ctx.params.v2("offset"));
p.u[0] = [10, ctx.params.e("fractalType"), octaves.ceil() as u32, f("seed") as u32 ^ 0x51ed];
p.u[1][0] = ctx.params.b("invert") as u32;
p.f[0] = [f("contrast") as f32 / 100.0, f("brightness") as f32 / 100.0, (f("scale") * b.scale).max(1.0) as f32, (octaves - octaves.floor()) as f32];
p.f[1] = [ox as f32, oy as f32, sr, cr];
p.f[2] = [f("evolution") as f32 / 360.0, f("blend") as f32 / 100.0, f("opacity") as f32 / 100.0, 0.0];
}
_ => return None,
}
let out = e.image(b.img.width, b.img.height);
e.pixels("pointwise", &p, &b.img, None, &out, None);
Some(GBuf { img: out, ..b })
}

127
crates/gpu/src/lib.rs Normal file
View File

@ -0,0 +1,127 @@
//! The EffectCraft GPU compositor (Mercury GPU Acceleration's counterpart), on wgpu compute
//! shaders: Metal, Vulkan, Direct3D 12 and WebGPU.
//!
//! The CPU [`Renderer`] stays the reference and keeps rendering layer *content* (sources, masks,
//! CPU effects, layer styles) into its layer cache. [`Gpu`] composites: it uploads the cached
//! 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).
//!
//! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
//! uploaded and read back once.
//!
//! 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 context;
mod effects;
mod ops;
mod walk;
use std::sync::Arc;
pub use context::{GpuContext, GpuImage};
use effectcraft_effects::Buf;
use effectcraft_project::ItemId;
use effectcraft_raster::Image;
use effectcraft_render::{Accelerator, FxStep, Renderer};
use effectcraft_time::Tick;
pub use wgpu;
use crate::context::Enc;
/// The GPU compositor (cheap to clone; one device shared by all clones).
#[derive(Clone)]
pub struct Gpu {
ctx: Arc<GpuContext>,
}
/// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`),
/// ready for `egui_wgpu::Renderer::register_native_texture`.
#[derive(Clone, Debug)]
pub struct DisplayFrame {
pub texture: wgpu::Texture,
pub width: u32,
pub height: u32,
}
impl Gpu {
/// On a device of its own (CLI, tests, benchmarks); `None` without a usable adapter.
#[cfg(not(target_arch = "wasm32"))]
pub fn headless() -> Option<Gpu> {
GpuContext::headless().map(Gpu::from_context)
}
/// On an existing device (the desktop app shares egui-wgpu's).
pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result<Gpu, String> {
GpuContext::new(adapter, device, queue).map(Gpu::from_context)
}
pub fn from_context(ctx: GpuContext) -> Gpu {
Gpu { ctx: Arc::new(ctx) }
}
pub fn context(&self) -> &GpuContext {
&self.ctx
}
pub fn device(&self) -> &wgpu::Device {
&self.ctx.device
}
/// Render a top-level comp frame and leave it on the GPU as a display texture (no
/// readback unless a CPU fallback step needs one). `None` = render on the CPU.
pub fn render_display(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<DisplayFrame> {
let mut e = Enc::new(&self.ctx);
let img = walk::render(&mut e, r, comp, t)?;
let texture = e.display(&img);
e.submit();
Some(DisplayFrame { texture, width: img.width, height: img.height })
}
/// Read a display frame back as premultiplied RGBA8 (Info panel sampling, eyedroppers).
pub fn read_display(&self, f: &DisplayFrame) -> Option<Vec<u8>> {
Enc::new(&self.ctx).read_texture(&f.texture, f.width, f.height, 4)
}
/// Render a frame on the GPU and read it back (`None` = not handled).
pub fn render(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
if !self.ctx.can_readback() {
return None;
}
let mut e = Enc::new(&self.ctx);
let img = walk::render(&mut e, r, comp, t)?;
e.download(&img)
}
/// Wait for all submitted GPU work (benchmarks).
pub fn wait(&self) {
let _ = self.ctx.device.poll(wgpu::PollType::wait_indefinitely());
}
}
impl Accelerator for Gpu {
fn name(&self) -> String {
self.ctx.name.clone()
}
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image> {
self.render(r, comp, t)
}
fn supports_effect(&self, id: &str) -> bool {
self.ctx.can_readback() && effects::supports(id)
}
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf> {
effects::run_chain(&mut Enc::new(&self.ctx), chain, buf, levels)
}
}
#[cfg(test)]
mod tests;

167
crates/gpu/src/ops.rs Normal file
View File

@ -0,0 +1,167 @@
//! Compositing primitives: each mirrors a CPU operation in `effectcraft-raster` /
//! `effectcraft-render` and returns a new image (GPU images are immutable).
use effectcraft_color::{BlendMode, Conversion};
use effectcraft_geom::{Mat3, Rect, vec2};
use effectcraft_project::MatteKind;
use effectcraft_raster::Sampling;
use crate::context::{Enc, GpuImage, Params};
/// Index of a blend mode in `BlendMode::ALL` (the kernels' mode numbers).
pub fn mode_id(m: BlendMode) -> u32 {
BlendMode::ALL.iter().position(|x| *x == m).unwrap_or(0) as u32
}
fn sampling_id(s: Sampling) -> u32 {
match s {
Sampling::Nearest => 0,
Sampling::Bilinear => 1,
Sampling::Bicubic => 2,
}
}
fn rows(m: &Mat3) -> [[f32; 4]; 3] {
m.0.map(|r| [r[0] as f32, r[1] as f32, r[2] as f32, 0.0])
}
/// Box-filter halving (minification pre-filter).
pub fn half(e: &mut Enc, src: &GpuImage) -> GpuImage {
let out = e.image(src.width.div_ceil(2).max(1), src.height.div_ceil(2).max(1));
e.pixels("half", &Params::default(), src, None, &out, None);
out
}
/// `raster::composite_warp` (and, with `accumulate = Some(weight)`, `accumulate_warp`): draw
/// `src` transformed by `m` (src pixels → dst pixels) onto `dst`.
pub fn warp(
e: &mut Enc,
dst: &GpuImage,
src: &GpuImage,
m: &Mat3,
sampling: Sampling,
mode: BlendMode,
opacity: f32,
seed: u32,
accumulate: Option<f32>,
) -> GpuImage {
if src.width == 0 || src.height == 0 || opacity <= 0.0 {
return dst.clone();
}
let Some(inv0) = m.inverse() else { return dst.clone() };
// Minification: pick a pre-filtered level so sampling doesn't alias.
let scale = m.mean_scale();
let mut img = src.clone();
let mut inv = inv0;
if scale < 0.5 && m.is_affine() {
let mut s = scale * 2.0;
let mut f = 0.5;
img = half(e, src);
while s < 0.5 && img.width > 1 && img.height > 1 {
img = half(e, &img);
f *= 0.5;
s *= 2.0;
}
inv = Mat3::scale(vec2(f, f)) * inv;
}
let bounds = m.map_rect(&Rect::from_size(src.width as f64, src.height as f64)).inflate(1.0).round_out();
let area = bounds.intersect(&Rect::from_size(dst.width as f64, dst.height as f64));
if area.is_empty() {
return dst.clone();
}
let (x0, x1) = (area.x0.max(0.0) as u32, area.x1.min(dst.width as f64) as u32);
let (y0, y1) = (area.y0.max(0.0) as u32, area.y1.min(dst.height as f64) as u32);
let mut p = Params::default();
let clear = matches!(mode, BlendMode::StencilAlpha | BlendMode::StencilLuma);
let flags = u32::from(accumulate.is_some()) | if clear && accumulate.is_none() { 2 } else { 0 };
p.u[0] = [mode_id(mode), sampling_id(sampling), seed, flags];
let r = rows(&inv);
p.f[0] = r[0];
p.f[1] = r[1];
p.f[2] = r[2];
p.f[3] = [opacity, accumulate.unwrap_or(1.0), 0.0, 0.0];
p.f[4] = [x0 as f32, y0 as f32, x1 as f32, y1 as f32];
let out = e.image(dst.width, dst.height);
e.pixels("warp_blend", &p, &img, Some(dst), &out, None);
out
}
/// `Image::blend_from`: blend `src` (same size) onto `dst`.
pub fn blend_full(e: &mut Enc, dst: &GpuImage, src: &GpuImage, mode: BlendMode, opacity: f32, seed: u32) -> GpuImage {
let mut p = Params::default();
p.u[0] = [mode_id(mode), 0, seed, 0];
p.f[0] = [opacity, 0.0, 0.0, 0.0];
let out = e.image(dst.width, dst.height);
e.pixels("blend_full", &p, src, Some(dst), &out, None);
out
}
/// Multiply an isolated layer by its track matte.
pub fn matte(e: &mut Enc, iso: &GpuImage, matte: &GpuImage, kind: MatteKind) -> GpuImage {
let mut p = Params::default();
p.u[0][0] = match kind {
MatteKind::Alpha => 0,
MatteKind::AlphaInverted => 1,
MatteKind::Luma => 2,
MatteKind::LumaInverted => 3,
};
let out = e.image(iso.width, iso.height);
e.pixels("matte", &p, iso, Some(matte), &out, None);
out
}
/// Preserve Transparency: multiply by the alpha below.
pub fn preserve(e: &mut Enc, iso: &GpuImage, canvas: &GpuImage) -> GpuImage {
let out = e.image(iso.width, iso.height);
e.pixels("preserve", &Params::default(), iso, Some(canvas), &out, None);
out
}
/// Knockout: clear what lies below the layer's content.
pub fn knockout(e: &mut Enc, canvas: &GpuImage, shape: &GpuImage) -> GpuImage {
let out = e.image(canvas.width, canvas.height);
e.pixels("knockout", &Params::default(), canvas, Some(shape), &out, None);
out
}
/// Styled-layer finish: move `canvas` toward `tmp` by `opacity` on the enabled channels.
pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bool; 3], opacity: f32) -> GpuImage {
let mut p = Params::default();
p.u[0] = [channels[0] as u32, channels[1] as u32, channels[2] as u32, 0];
p.f[0][0] = opacity;
let out = e.image(canvas.width, canvas.height);
e.pixels("channel_mix", &p, canvas, Some(tmp), &out, None);
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();
p.f[0][0] = levels;
let out = e.image(img.width, img.height);
e.pixels("quantize", &p, img, None, &out, None);
out
}
fn curve_id(s: Option<effectcraft_color::ColorSpace>) -> u32 {
use effectcraft_color::ColorSpace::*;
match s {
None => 0,
Some(Srgb | DisplayP3) => 1,
Some(Rec709 | Rec2020) => 2,
}
}
/// Colour conversion on straight colour (`render::color::convert`).
pub fn convert(e: &mut Enc, img: &GpuImage, c: &Conversion) -> GpuImage {
let mut p = Params::default();
p.u[0] = [curve_id(c.decode), c.matrix.is_some() as u32, curve_id(c.encode), 0];
if let Some(m) = c.matrix {
for i in 0..3 {
p.f[i] = [m[i][0], m[i][1], m[i][2], 0.0];
}
}
let out = e.image(img.width, img.height);
e.pixels("convert", &p, img, None, &out, None);
out
}

View File

@ -0,0 +1,452 @@
// EffectCraft GPU compositor: shared bindings and helpers.
//
// Every kernel uses the same bind group: a uniform block of parameters, two input textures
// (premultiplied RGBA f32, read with textureLoad), one output storage texture and a read-only
// storage buffer of extra data (curve tables). Each helper mirrors a CPU function in
// effectcraft-raster / effectcraft-color operation for operation, so results match the CPU
// reference to float rounding.
struct Params {
u: array<vec4<u32>, 4>,
f: array<vec4<f32>, 12>,
}
@group(0) @binding(0) var<uniform> P: Params;
@group(0) @binding(1) var src: texture_2d<f32>;
@group(0) @binding(2) var aux: texture_2d<f32>;
@group(0) @binding(4) var<storage, read> data: array<f32>;
// ---------------------------------------------------------------- sampling (raster::Image)
fn tex_get(t: texture_2d<f32>, x: i32, y: i32) -> vec4<f32> {
let d = vec2<i32>(textureDimensions(t));
if (x < 0 || y < 0 || x >= d.x || y >= d.y) {
return vec4<f32>(0.0);
}
return textureLoad(t, vec2<i32>(x, y), 0);
}
fn tex_get_clamped(t: texture_2d<f32>, x: i32, y: i32) -> vec4<f32> {
let d = vec2<i32>(textureDimensions(t));
return textureLoad(t, vec2<i32>(clamp(x, 0, d.x - 1), clamp(y, 0, d.y - 1)), 0);
}
// Image::sample_bilinear (pixel centres at +0.5; transparent outside).
fn sample_bilinear(t: texture_2d<f32>, x: f32, y: f32) -> vec4<f32> {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = floor(fx);
let y0 = floor(fy);
let tx = fx - x0;
let ty = fy - y0;
let xi = i32(x0);
let yi = i32(y0);
let a = tex_get(t, xi, yi);
let b = tex_get(t, xi + 1, yi);
let c = tex_get(t, xi, yi + 1);
let d = tex_get(t, xi + 1, yi + 1);
let top = a + (b - a) * tx;
let bot = c + (d - c) * tx;
return top + (bot - top) * ty;
}
// Image::sample_bilinear_clamped (edge pixels repeat).
fn sample_bilinear_clamped(t: texture_2d<f32>, x: f32, y: f32) -> vec4<f32> {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = floor(fx);
let y0 = floor(fy);
let tx = fx - x0;
let ty = fy - y0;
let xi = i32(x0);
let yi = i32(y0);
let a = tex_get_clamped(t, xi, yi);
let b = tex_get_clamped(t, xi + 1, yi);
let c = tex_get_clamped(t, xi, yi + 1);
let d = tex_get_clamped(t, xi + 1, yi + 1);
let top = a + (b - a) * tx;
let bot = c + (d - c) * tx;
return top + (bot - top) * ty;
}
fn cubic_w(t: f32) -> vec4<f32> {
let t2 = t * t;
let t3 = t2 * t;
return vec4<f32>(-0.5 * t3 + t2 - 0.5 * t, 1.5 * t3 - 2.5 * t2 + 1.0, -1.5 * t3 + 2.0 * t2 + 0.5 * t, 0.5 * t3 - 0.5 * t2);
}
// Image::sample_bicubic (Catmull-Rom; negatives clamped after ringing).
fn sample_bicubic(t: texture_2d<f32>, x: f32, y: f32) -> vec4<f32> {
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = floor(fx);
let y0 = floor(fy);
let wx = cubic_w(fx - x0);
let wy = cubic_w(fy - y0);
let xi = i32(x0);
let yi = i32(y0);
var o = vec4<f32>(0.0);
for (var j = 0; j < 4; j++) {
for (var i = 0; i < 4; i++) {
let p = tex_get(t, xi - 1 + i, yi - 1 + j);
o += p * (wx[i] * wy[j]);
}
}
return max(o, vec4<f32>(0.0));
}
// 0 nearest, 1 bilinear, 2 bicubic (raster::Sampling).
fn sample(t: texture_2d<f32>, sampling: u32, x: f32, y: f32) -> vec4<f32> {
if (sampling == 0u) {
return tex_get(t, i32(floor(x)), i32(floor(y)));
}
if (sampling == 2u) {
return sample_bicubic(t, x, y);
}
return sample_bilinear(t, x, y);
}
// raster::hash_noise.
fn hash_noise(x: u32, y: u32, seed: u32) -> f32 {
var h = (x * 0x8da6b343u) ^ (y * 0xd8163841u) ^ (seed * 0xcb1ab31fu);
h = h ^ (h >> 13u);
h = h * 0x5bd1e995u;
h = h ^ (h >> 15u);
return f32(h & 0x00ffffffu) / 16777216.0;
}
// powf for x >= 0 (0^y = 0 for y > 0; WGSL leaves pow(0, y) to the implementation).
fn powz(x: f32, y: f32) -> f32 {
if (x <= 0.0) {
return select(0.0, 1.0, y == 0.0);
}
return pow(x, y);
}
// Rust's f32::round (half away from zero; WGSL's round() is half to even).
fn round_away(x: f32) -> f32 {
let t = trunc(x);
if (abs(x - t) >= 0.5) {
return t + sign(x);
}
return t;
}
// f32::rem_euclid(1.0).
fn fract_euclid(x: f32) -> f32 {
return x - floor(x);
}
fn luminance(c: vec3<f32>) -> f32 {
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
}
// ---------------------------------------------------------------- blend modes (color::blend)
// Mode numbers are indices into BlendMode::ALL.
fn burn(cb: f32, cs: f32) -> f32 {
if (cb >= 1.0) {
return 1.0;
}
if (cs <= 0.0) {
return 0.0;
}
return 1.0 - min((1.0 - cb) / cs, 1.0);
}
fn dodge(cb: f32, cs: f32) -> f32 {
if (cb <= 0.0) {
return 0.0;
}
if (cs >= 1.0) {
return 1.0;
}
return min(cb / (1.0 - cs), 1.0);
}
fn hard_light(cb: f32, cs: f32) -> f32 {
if (cs <= 0.5) {
return cb * 2.0 * cs;
}
let s = 2.0 * cs - 1.0;
return cb + s - cb * s;
}
fn soft_light(cb: f32, cs: f32) -> f32 {
if (cs <= 0.5) {
return cb - (1.0 - 2.0 * cs) * cb * (1.0 - cb);
}
var d: f32;
if (cb <= 0.25) {
d = ((16.0 * cb - 12.0) * cb + 4.0) * cb;
} else {
d = sqrt(max(cb, 0.0));
}
return cb + (2.0 * cs - 1.0) * (d - cb);
}
fn vivid(cb: f32, cs: f32) -> f32 {
if (cs <= 0.5) {
return burn(cb, 2.0 * cs);
}
return dodge(cb, 2.0 * (cs - 0.5));
}
fn separable(mode: u32, cb: f32, cs: f32) -> f32 {
switch mode {
case 3u: { return min(cb, cs); }
case 4u: { return cb * cs; }
case 5u: { return burn(cb, cs); }
case 6u: {
if (cs <= 0.0) {
return 0.0;
}
return max(1.0 - (1.0 - cb) / cs, 0.0);
}
case 7u: { return max(cb + cs - 1.0, 0.0); }
case 9u, 14u: { return cb + cs; }
case 10u: { return max(cb, cs); }
case 11u: { return cb + cs - cb * cs; }
case 12u: { return dodge(cb, cs); }
case 13u: {
if (cs >= 1.0) {
return 1.0;
}
return min(cb / (1.0 - cs), 1.0);
}
case 16u: { return hard_light(cs, cb); }
case 17u: { return soft_light(cb, cs); }
case 18u: { return hard_light(cb, cs); }
case 19u: { return clamp(cb + 2.0 * cs - 1.0, 0.0, 1.0); }
case 20u: { return vivid(cb, cs); }
case 21u: {
if (cs <= 0.5) {
return min(cb, 2.0 * cs);
}
return max(cb, 2.0 * cs - 1.0);
}
case 22u: {
if (cb + cs >= 1.0) {
return 1.0;
}
return 0.0;
}
case 23u, 24u: { return abs(cb - cs); }
case 25u: { return cb + cs - 2.0 * cb * cs; }
case 26u: { return max(cb - cs, 0.0); }
case 27u: {
if (cs <= 0.0) {
if (cb > 0.0) {
return 1.0;
}
return 0.0;
}
return cb / cs;
}
default: { return cs; }
}
}
fn blum(c: vec3<f32>) -> f32 {
return 0.3 * c.x + 0.59 * c.y + 0.11 * c.z;
}
fn clip_color(c: vec3<f32>) -> vec3<f32> {
let l = blum(c);
let n = min(min(c.x, c.y), c.z);
let x = max(max(c.x, c.y), c.z);
var o = c;
if (n < 0.0) {
o = l + (o - l) * l / max(l - n, 1e-9);
}
if (x > 1.0) {
o = l + (o - l) * (1.0 - l) / max(x - l, 1e-9);
}
return o;
}
fn set_lum(c: vec3<f32>, l: f32) -> vec3<f32> {
let d = l - blum(c);
return clip_color(c + d);
}
fn bsat(c: vec3<f32>) -> f32 {
return max(max(c.x, c.y), c.z) - min(min(c.x, c.y), c.z);
}
fn set_sat(c: vec3<f32>, s: f32) -> vec3<f32> {
let mx = max(max(c.x, c.y), c.z);
let mn = min(min(c.x, c.y), c.z);
if (mx - mn <= 1e-9) {
return vec3<f32>(0.0);
}
return (c - mn) * s / (mx - mn);
}
fn non_separable(mode: u32, cb: vec3<f32>, cs: vec3<f32>) -> vec3<f32> {
switch mode {
case 28u: { return set_lum(set_sat(cs, bsat(cb)), blum(cb)); }
case 29u: { return set_lum(set_sat(cb, bsat(cs)), blum(cb)); }
case 30u: { return set_lum(cs, blum(cb)); }
case 31u: { return set_lum(cb, blum(cs)); }
case 8u: {
if (luminance(cs) < luminance(cb)) {
return cs;
}
return cb;
}
case 15u: {
if (luminance(cs) > luminance(cb)) {
return cs;
}
return cb;
}
default: {
return vec3<f32>(separable(mode, cb.x, cs.x), separable(mode, cb.y, cs.y), separable(mode, cb.z, cs.z));
}
}
}
fn supports_hdr(mode: u32) -> bool {
switch mode {
case 0u, 1u, 2u, 3u, 4u, 7u, 8u, 9u, 10u, 11u, 14u, 15u, 23u, 24u, 26u, 27u, 32u, 33u, 34u, 35u, 36u, 37u: { return true; }
default: { return false; }
}
}
fn is_stencil(mode: u32) -> bool {
return mode >= 32u && mode <= 35u;
}
// color::blend_pixel.
fn blend_pixel(mode: u32, dst: vec4<f32>, src: vec4<f32>, noise: f32) -> vec4<f32> {
let sa = src.w;
let da = dst.w;
switch mode {
case 0u: {
let k = 1.0 - sa;
return vec4<f32>(src.xyz + dst.xyz * k, sa + da * k);
}
case 1u, 2u: {
if (sa <= 0.0 || noise >= sa) {
return dst;
}
let inv = 1.0 / sa;
return vec4<f32>(src.xyz * inv, 1.0);
}
case 32u: { return dst * sa; }
case 34u: { return dst * (1.0 - sa); }
case 33u: { return dst * luminance(src.xyz); }
case 35u: { return dst * (1.0 - luminance(src.xyz)); }
case 36u: { return vec4<f32>(src.xyz + dst.xyz * (1.0 - sa), min(sa + da, 1.0)); }
case 37u: { return vec4<f32>(src.xyz + dst.xyz * (1.0 - sa), sa + da - sa * da); }
default: {}
}
if (sa <= 0.0) {
return dst;
}
if (da <= 0.0) {
return src;
}
var cs = src.xyz / sa;
var cb = dst.xyz / da;
if (!supports_hdr(mode)) {
cs = clamp(cs, vec3<f32>(0.0), vec3<f32>(1.0));
cb = clamp(cb, vec3<f32>(0.0), vec3<f32>(1.0));
}
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
return vec4<f32>((1.0 - da) * src.xyz + (1.0 - sa) * dst.xyz + sa * da * b, ao);
}
// ---------------------------------------------------------------- colour (color crate)
fn srgb_to_linear(v: f32) -> f32 {
if (v <= 0.04045) {
return v / 12.92;
}
return pow((v + 0.055) / 1.055, 2.4);
}
fn linear_to_srgb(v: f32) -> f32 {
if (v <= 0.0031308) {
return v * 12.92;
}
return 1.055 * pow(max(v, 0.0), 1.0 / 2.4) - 0.055;
}
fn rgb_to_hsl(c: vec3<f32>) -> vec3<f32> {
let mx = max(max(c.x, c.y), c.z);
let mn = min(min(c.x, c.y), c.z);
let l = (mx + mn) * 0.5;
if (abs(mx - mn) < 1e-7) {
return vec3<f32>(0.0, 0.0, l);
}
let d = mx - mn;
var s: f32;
if (l > 0.5) {
s = d / (2.0 - mx - mn);
} else {
s = d / (mx + mn);
}
var h: f32;
if (mx == c.x) {
h = ((c.y - c.z) / d + select(0.0, 6.0, c.y < c.z)) / 6.0;
} else if (mx == c.y) {
h = ((c.z - c.x) / d + 2.0) / 6.0;
} else {
h = ((c.x - c.y) / d + 4.0) / 6.0;
}
return vec3<f32>(h, s, l);
}
fn hsl_channel(t0: f32, p: f32, q: f32) -> f32 {
let t = fract_euclid(t0);
if (t < 1.0 / 6.0) {
return p + (q - p) * 6.0 * t;
}
if (t < 0.5) {
return q;
}
if (t < 2.0 / 3.0) {
return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
}
return p;
}
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> vec3<f32> {
if (s <= 0.0) {
return vec3<f32>(l);
}
var q: f32;
if (l < 0.5) {
q = l * (1.0 + s);
} else {
q = l + s - l * s;
}
let p = 2.0 * l - q;
return vec3<f32>(hsl_channel(h + 1.0 / 3.0, p, q), hsl_channel(h, p, q), hsl_channel(h - 1.0 / 3.0, p, q));
}
// ColorSpace transfer curves: 1 = sRGB (sRGB, Display P3), 2 = 2.4 power (Rec. 709 / 2020).
fn curve_decode(curve: u32, v: f32) -> f32 {
let a = abs(v);
var l = a;
if (curve == 1u) {
l = srgb_to_linear(a);
} else if (curve == 2u) {
l = powz(a, 2.4);
}
return select(l, -l, v < 0.0);
}
fn curve_encode(curve: u32, v: f32) -> f32 {
let a = abs(v);
var e = a;
if (curve == 1u) {
e = linear_to_srgb(a);
} else if (curve == 2u) {
e = powz(a, 1.0 / 2.4);
}
return select(e, -e, v < 0.0);
}

View File

@ -0,0 +1,19 @@
// Viewer display: premultiplied f32 → premultiplied RGBA8 (ui-egui's frames::to_color_image),
// written straight into a texture egui samples.
@group(0) @binding(1) var dsrc: texture_2d<f32>;
@group(0) @binding(3) var dout: texture_storage_2d<rgba8unorm, write>;
@compute @workgroup_size(16, 16)
fn display(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = vec2<i32>(textureDimensions(dout));
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let s = textureLoad(dsrc, p, 0);
let a = clamp(s.w, 0.0, 1.0);
// (v · 255 + 0.5) truncated, as the CPU conversion.
let q = floor(vec4<f32>(clamp(s.xyz, vec3<f32>(0.0), vec3<f32>(a)), a) * 255.0 + 0.5) / 255.0;
textureStore(dout, p, q);
}

View File

@ -0,0 +1,750 @@
// Compositing and effect kernels (rgba32float output). Parameter layouts are documented next to
// each entry point and mirrored by the Rust dispatch code (src/ops.rs, src/effects.rs).
@group(0) @binding(3) var out: texture_storage_2d<rgba32float, write>;
fn out_dims() -> vec2<i32> {
return vec2<i32>(textureDimensions(out));
}
// ---------------------------------------------------------------- compositing
// Warp `src` by the inverse matrix (output pixel → source pixel) and blend over `aux`
// (raster::composite_warp / accumulate_warp).
// u[0] = (mode, sampling, seed, flags: 1 accumulate, 2 clear outside bounds)
// f[0..3] = inverse rows; f[3] = (opacity, weight); f[4] = bounds (x0, y0, x1, y1)
@compute @workgroup_size(16, 16)
fn warp_blend(@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 mode = P.u[0].x;
let sampling = P.u[0].y;
let seed = P.u[0].z;
let flags = P.u[0].w;
let b = P.f[4];
var d = textureLoad(aux, vec2<i32>(x, y), 0);
let fx = f32(x) + 0.5;
let fy = f32(y) + 0.5;
if (fx < b.x || fy < b.y || fx > b.z || fy > b.w) {
if ((flags & 2u) != 0u) {
d = vec4<f32>(0.0);
}
textureStore(out, vec2<i32>(x, y), d);
return;
}
let r0 = P.f[0];
let r1 = P.f[1];
let r2 = P.f[2];
var px = r0.x * fx + r0.y * fy + r0.z;
var py = r1.x * fx + r1.y * fy + r1.z;
let w = r2.x * fx + r2.y * fy + r2.z;
if (w != 1.0 && w != 0.0) {
px = px / w;
py = py / w;
}
let sz = vec2<f32>(textureDimensions(src));
let stencil = is_stencil(mode);
if (px < -1.0 || py < -1.0 || px > sz.x + 1.0 || py > sz.y + 1.0) {
if (stencil && (flags & 1u) == 0u) {
d = blend_pixel(mode, d, vec4<f32>(0.0), 0.5);
}
textureStore(out, vec2<i32>(x, y), d);
return;
}
var s = sample(src, sampling, px, py);
if ((flags & 1u) != 0u) {
if (s.w > 0.0) {
d = d + s * P.f[3].y;
}
textureStore(out, vec2<i32>(x, y), d);
return;
}
if (s.w <= 0.0 && !stencil) {
textureStore(out, vec2<i32>(x, y), d);
return;
}
let op = P.f[3].x;
if (op < 1.0) {
s = s * op;
}
var n = 0.5;
if (mode == 1u || mode == 2u) {
n = hash_noise(u32(x), u32(y), seed);
}
textureStore(out, vec2<i32>(x, y), blend_pixel(mode, d, s, n));
}
// Blend `src` (same size) onto `aux` (Image::blend_from).
// u[0] = (mode, _, seed); f[0].x = opacity
@compute @workgroup_size(16, 16)
fn blend_full(@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 d = textureLoad(aux, p, 0);
var s = textureLoad(src, p, 0);
if (s.w <= 0.0 && !is_stencil(mode)) {
textureStore(out, p, d);
return;
}
s = s * P.f[0].x;
var n = 0.5;
if (mode == 1u || mode == 2u) {
n = hash_noise(gid.x, gid.y, P.u[0].z);
}
textureStore(out, p, blend_pixel(mode, d, s, n));
}
// Track matte: `src` × matte factor of `aux`. u[0].x = 0 alpha, 1 alpha inverted, 2 luma,
// 3 luma inverted.
@compute @workgroup_size(16, 16)
fn matte(@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 q = textureLoad(aux, p, 0);
var k: f32;
switch P.u[0].x {
case 0u: { k = q.w; }
case 1u: { k = 1.0 - q.w; }
case 2u: { k = luminance(q.xyz); }
default: { k = 1.0 - luminance(q.xyz); }
}
k = clamp(k, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// Preserve Transparency: `src` × alpha of `aux`.
@compute @workgroup_size(16, 16)
fn preserve(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
textureStore(out, p, textureLoad(src, p, 0) * textureLoad(aux, p, 0).w);
}
// Knockout: `src` × (1 − alpha of `aux`).
@compute @workgroup_size(16, 16)
fn knockout(@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 keep = 1.0 - clamp(textureLoad(aux, p, 0).w, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * keep);
}
// Styled layer finish: canvas `src` moves toward `aux` by the opacity, per enabled channel.
// u[0].xyz = R/G/B switches; f[0].x = opacity.
@compute @workgroup_size(16, 16)
fn channel_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 t = textureLoad(aux, p, 0);
let op = P.f[0].x;
var v = t;
if (P.u[0].x == 0u) {
v.x = c.x;
}
if (P.u[0].y == 0u) {
v.y = c.y;
}
if (P.u[0].z == 0u) {
v.z = c.z;
}
textureStore(out, p, c + (v - c) * op);
}
// 8/16 bpc: clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round. f[0].x = levels.
@compute @workgroup_size(16, 16)
fn quantize(@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 levels = P.f[0].x;
let inv = 1.0 / levels;
let s = textureLoad(src, p, 0);
let a = round_away(clamp(s.w, 0.0, 1.0) * levels) * inv;
var o = vec4<f32>(0.0, 0.0, 0.0, a);
o.x = round_away(clamp(s.x, 0.0, a) * levels) * inv;
o.y = round_away(clamp(s.y, 0.0, a) * levels) * inv;
o.z = round_away(clamp(s.z, 0.0, a) * levels) * inv;
textureStore(out, p, o);
}
// Colour conversion on straight colour (render::color::convert).
// u[0] = (decode curve, has matrix, encode curve); f[0..3] = matrix rows.
@compute @workgroup_size(16, 16)
fn convert(@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 s = textureLoad(src, p, 0);
let a = s.w;
if (a <= 0.0) {
textureStore(out, p, s);
return;
}
var c = s.xyz / a;
let dc = P.u[0].x;
if (dc != 0u) {
c = vec3<f32>(curve_decode(dc, c.x), curve_decode(dc, c.y), curve_decode(dc, c.z));
}
if (P.u[0].y != 0u) {
let m0 = P.f[0].xyz;
let m1 = P.f[1].xyz;
let m2 = P.f[2].xyz;
c = vec3<f32>(m0.x * c.x + m0.y * c.y + m0.z * c.z, m1.x * c.x + m1.y * c.y + m1.z * c.z, m2.x * c.x + m2.y * c.y + m2.z * c.z);
}
let ec = P.u[0].z;
if (ec != 0u) {
c = vec3<f32>(curve_encode(ec, c.x), curve_encode(ec, c.y), curve_encode(ec, c.z));
}
textureStore(out, p, vec4<f32>(c * a, a));
}
// Box-filter halving for minification (raster::warp::half).
@compute @workgroup_size(16, 16)
fn half(@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 = 2 * p.x;
let sy = 2 * p.y;
var acc = vec4<f32>(0.0);
acc += tex_get_clamped(src, sx, sy);
acc += tex_get_clamped(src, sx + 1, sy);
acc += tex_get_clamped(src, sx, sy + 1);
acc += tex_get_clamped(src, sx + 1, sy + 1);
textureStore(out, p, acc * 0.25);
}
// ---------------------------------------------------------------- blurs (raster::blur)
fn line_get(horizontal: bool, line: i32, i: i32, n: i32, repeat: bool) -> vec4<f32> {
var j = i;
if (i < 0 || i >= n) {
if (!repeat) {
return vec4<f32>(0.0);
}
j = clamp(i, 0, n - 1);
}
if (horizontal) {
return textureLoad(src, vec2<i32>(j, line), 0);
}
return textureLoad(src, vec2<i32>(line, j), 0);
}
// One box pass of radius r along rows (horizontal) or columns, as running sums over blocks of
// `block` pixels (each block restarts its sum, as the CPU's vertical pass does).
fn box_pass(gid: vec3<u32>, horizontal: bool) {
let dims = out_dims();
let r = i32(P.u[0].x);
let repeat = P.u[0].y != 0u;
let block = i32(P.u[0].z);
var lines = dims.x;
var n = dims.y;
if (horizontal) {
lines = dims.y;
n = dims.x;
}
let line = i32(gid.x);
let b0 = i32(gid.y) * block;
if (line >= lines || b0 >= n) {
return;
}
let norm = 1.0 / f32(2 * r + 1);
var acc = vec4<f32>(0.0);
for (var i = b0 - r; i <= b0 + r; i++) {
acc += line_get(horizontal, line, i, n, repeat);
}
let b1 = min(b0 + block, n);
for (var i = b0; i < b1; i++) {
if (horizontal) {
textureStore(out, vec2<i32>(i, line), acc * norm);
} else {
textureStore(out, vec2<i32>(line, i), acc * norm);
}
let add = line_get(horizontal, line, i + r + 1, n, repeat);
let sub = line_get(horizontal, line, i - r, n, repeat);
acc += add - sub;
}
}
// u[0] = (radius, repeat edges, block); dispatched over (line, block).
@compute @workgroup_size(64, 1)
fn box_h(@builtin(global_invocation_id) gid: vec3<u32>) {
box_pass(gid, true);
}
@compute @workgroup_size(64, 1)
fn box_v(@builtin(global_invocation_id) gid: vec3<u32>) {
box_pass(gid, false);
}
// Directional blur (raster::directional_blur). f[0] = (dx, dy, length, samples).
@compute @workgroup_size(16, 16)
fn directional(@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 dx = P.f[0].x;
let dy = P.f[0].y;
let len = P.f[0].z;
let n = i32(P.f[0].w);
var acc = vec4<f32>(0.0);
for (var i = 0; i < n; i++) {
let t = (f32(i) / f32(n - 1) - 0.5) * len;
acc += sample_bilinear(src, f32(p.x) + 0.5 + dx * t, f32(p.y) + 0.5 + dy * t);
}
textureStore(out, p, acc / f32(n));
}
// ---------------------------------------------------------------- Glow / Drop Shadow
// Glow bright pass. u[0].x = A & B colours; f[0].x = threshold; f[1] = colour A; f[2] = colour B.
@compute @workgroup_size(16, 16)
fn glow_bright(@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 a = px.w;
if (a <= 0.0) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
let thr = P.f[0].x;
let l = luminance(px.xyz / a);
let k = clamp((l - thr) / max(1.0 - thr, 1e-3), 0.0, 1.0);
if (P.u[0].x != 0u) {
let t = clamp(l, 0.0, 1.0);
let ca = P.f[1].xyz;
let cb = P.f[2].xyz;
let c = ca + (cb - ca) * t;
textureStore(out, p, vec4<f32>(c * k * a, k * a));
} else {
textureStore(out, p, px * k);
}
}
// Glow composite: original `src` with the blurred glow `aux`. u[0].x = behind; f[0].x = intensity.
@compute @workgroup_size(16, 16)
fn glow_combine(@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 o = textureLoad(src, p, 0);
let g = textureLoad(aux, p, 0) * P.f[0].x;
if (P.u[0].x != 0u) {
let k = 1.0 - o.w;
o = o + g * k;
} else {
o = vec4<f32>(o.xyz + g.xyz, min(o.w + g.w * (1.0 - o.w), 1.0));
}
o.w = clamp(o.w, 0.0, 1.0);
textureStore(out, p, o);
}
// Drop shadow silhouette. f[0] = shadow colour; f[1] = (opacity, dx, dy).
@compute @workgroup_size(16, 16)
fn shadow_make(@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 = P.f[0];
let a = sample_bilinear(src, f32(p.x) + 0.5 - P.f[1].y, f32(p.y) + 0.5 - P.f[1].z).w * P.f[1].x * c.w;
textureStore(out, p, vec4<f32>(c.xyz * a, a));
}
// The layer over its shadow: `aux` (original) over `src` (shadow).
@compute @workgroup_size(16, 16)
fn shadow_combine(@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 s = textureLoad(src, p, 0);
let o = textureLoad(aux, p, 0);
textureStore(out, p, o + s * (1.0 - o.w));
}
// ---------------------------------------------------------------- per-pixel effects
fn mix3(a: vec3<f32>, b: vec3<f32>, t: f32) -> vec3<f32> {
return a + (b - a) * t;
}
// Curves: data = [n, xs.., ys.., ms.., lut (1025)] per channel at offsets f[c].
fn curve_exact(base: u32, x: f32) -> f32 {
let n = u32(data[base]);
let xs = base + 1u;
let ys = xs + n;
let ms = ys + n;
if (x <= data[xs]) {
return data[ys] + (x - data[xs]) * data[ms];
}
if (x >= data[xs + n - 1u]) {
return data[ys + n - 1u] + (x - data[xs + n - 1u]) * data[ms + n - 1u];
}
// partition_point(|v| v <= x) - 1, clamped to n - 2.
var k = 0u;
for (var i = 0u; i < n; i++) {
if (data[xs + i] <= x) {
k = i + 1u;
}
}
k = min(max(k, 1u) - 1u, n - 2u);
let h = data[xs + k + 1u] - data[xs + k];
let t = (x - data[xs + k]) / h;
let t2 = t * t;
let t3 = t * t * t;
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
let h11 = t3 - t2;
return h00 * data[ys + k] + h10 * h * data[ms + k] + h01 * data[ys + k + 1u] + h11 * h * data[ms + k + 1u];
}
fn curve_eval(base_f: f32, x: f32) -> f32 {
if (base_f < 0.0) {
return x;
}
let base = u32(base_f);
if (!(x >= 0.0 && x <= 1.0)) {
return curve_exact(base, x);
}
let n = u32(data[base]);
let lut = base + 1u + 3u * n;
let f = x * 1024.0;
let i = min(u32(f), 1023u);
let t = f - f32(i);
return data[lut + i] + (data[lut + i + 1u] - data[lut + i]) * t;
}
// Fractal Noise lattice (effects::generate::lattice / value_noise).
fn lattice(ix: i32, iy: i32, iz: i32, seed: u32) -> f32 {
var h = (bitcast<u32>(ix) * 0x27d4eb2du) ^ (bitcast<u32>(iy) * 0x165667b1u) ^ (bitcast<u32>(iz) * 0x9e3779b9u) ^ (seed * 0x85ebca6bu);
h = h ^ (h >> 15u);
h = h * 0x2c1b3c6du;
h = h ^ (h >> 12u);
h = h * 0x297a2d39u;
h = h ^ (h >> 15u);
return f32(h & 0xffffu) / 65535.0;
}
fn fade(t: f32) -> f32 {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
fn value_noise(x: f32, y: f32, z: f32, seed: u32) -> f32 {
let x0 = floor(x);
let y0 = floor(y);
let z0 = floor(z);
let fx = fade(x - x0);
let fy = fade(y - y0);
let fz = fade(z - z0);
let ix = i32(x0);
let iy = i32(y0);
let iz = i32(z0);
let c000 = lattice(ix, iy, iz, seed);
let c100 = lattice(ix + 1, iy, iz, seed);
let c010 = lattice(ix, iy + 1, iz, seed);
let c110 = lattice(ix + 1, iy + 1, iz, seed);
let c001 = lattice(ix, iy, iz + 1, seed);
let c101 = lattice(ix + 1, iy, iz + 1, seed);
let c011 = lattice(ix, iy + 1, iz + 1, seed);
let c111 = lattice(ix + 1, iy + 1, iz + 1, seed);
let l00 = c000 + (c100 - c000) * fx;
let l01 = c010 + (c110 - c010) * fx;
let a = l00 + (l01 - l00) * fy;
let l10 = c001 + (c101 - c001) * fx;
let l11 = c011 + (c111 - c011) * fx;
let b = l10 + (l11 - l10) * fy;
return a + (b - a) * fz;
}
// generate::put: blend a generated straight colour (alpha ga) over the pixel.
fn put(px: vec4<f32>, g: vec3<f32>, ga: f32, blend_orig: f32) -> vec4<f32> {
let g2 = vec4<f32>(g * ga, ga);
let k = 1.0 - blend_orig;
return px * blend_orig + g2 * k;
}
// Straight-colour map helper: skips transparent pixels (Image::map_straight).
fn straight(px: vec4<f32>) -> vec3<f32> {
return px.xyz / px.w;
}
// u[0].x = op:
// 1 Tint f[0] = black, f[1] = white, f[2].x = amount
// 2 Bright/Contrast f[0] = (brightness, k)
// 3 Hue/Saturation u[0].y = colorize; f[0] = (hue, sat, light); f[1] = (ch, cs, cl)
// 4 Levels f[0] = (ib, iw, g, ob); f[1] = (ow); u[0].y = clip
// 5 Exposure f[0] = (e, offset, gamma)
// 6 Invert u[0].y = channel; f[0].x = blend
// 7 Fill f[0] = colour; f[1] = (opacity); u[0].y = invert
// 8 Curves f[0] = data offsets (rgb, red, green, blue), f[1].x = alpha (−1 = none)
// 9 Gradient Ramp f[0] = (sx, sy, ex, ey); f[1] = start colour; f[2] = end colour;
// f[3] = (len2, scatter, blend); u[0] = (_, radial, seed)
// 10 Fractal Noise f[0] = (contrast, brightness, scale, frac); f[1] = (ox, oy, sr, cr);
// f[2] = (evo, blend, opacity); u[0] = (_, kind, n_oct, seed); u[1].x = invert
@compute @workgroup_size(16, 16)
fn pointwise(@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;
let op = P.u[0].x;
switch op {
case 1u: {
if (a > 0.0) {
let c = straight(px);
let l = clamp(luminance(c), 0.0, 1.0);
let blk = P.f[0].xyz;
let wht = P.f[1].xyz;
let t = blk + (wht - blk) * l;
px = vec4<f32>(mix3(c, t, P.f[2].x) * a, a);
}
}
case 2u: {
if (a > 0.0) {
let c = straight(px);
let br = P.f[0].x;
let k = P.f[0].y;
px = vec4<f32>(max((c + br - 0.5) * k + 0.5, vec3<f32>(0.0)) * a, a);
}
}
case 3u: {
if (a > 0.0) {
let c = straight(px);
let hsl = rgb_to_hsl(c);
var h = hsl.x;
var s = hsl.y;
var l = hsl.z;
if (P.u[0].y != 0u) {
let cl = P.f[1].z;
var k = l;
if (cl > 0.0) {
k = 1.0 - l;
}
h = P.f[1].x;
s = P.f[1].y;
l = clamp(l + cl * k, 0.0, 1.0);
} else {
let hue = P.f[0].x;
let sat = P.f[0].y;
let light = P.f[0].z;
var s2: f32;
if (sat >= 0.0) {
s2 = s + (1.0 - s) * sat * min(s, 1.0);
} else {
s2 = s * (1.0 + sat);
}
var l2: f32;
if (light >= 0.0) {
l2 = l + (1.0 - l) * light;
} else {
l2 = l * (1.0 + light);
}
h = fract_euclid(h + hue);
s = clamp(s2, 0.0, 1.0);
l = l2;
}
px = vec4<f32>(hsl_to_rgb(h, s, l) * a, a);
}
}
case 4u: {
if (a > 0.0) {
let c = straight(px);
let ib = P.f[0].x;
let iw = P.f[0].y;
let g = P.f[0].z;
let ob = P.f[0].w;
let ow = P.f[1].x;
var o = vec3<f32>(0.0);
for (var i = 0; i < 3; i++) {
var t = (c[i] - ib) / max(iw - ib, 1e-6);
if (P.u[0].y != 0u) {
t = clamp(t, 0.0, 1.0);
}
t = powz(max(t, 0.0), 1.0 / g);
o[i] = ob + (ow - ob) * t;
}
px = vec4<f32>(o * a, a);
}
}
case 5u: {
if (a > 0.0) {
let c = straight(px);
var o = vec3<f32>(0.0);
for (var i = 0; i < 3; i++) {
let lin = srgb_to_linear(max(c[i], 0.0));
let v = powz(max(lin * P.f[0].x + P.f[0].y, 0.0), 1.0 / P.f[0].z);
o[i] = linear_to_srgb(v);
}
px = vec4<f32>(o * a, a);
}
}
case 6u: {
let ch = P.u[0].y;
let blend = P.f[0].x;
if (ch == 4u) {
let na = 1.0 - a;
var k = 0.0;
if (a > 0.0) {
k = na / a;
}
let inv = vec4<f32>(px.xyz * k, na);
px = px + (inv - px) * blend;
} else if (a > 0.0) {
let s = straight(px);
var o = s;
switch ch {
case 1u: { o.x = 1.0 - s.x; }
case 2u: { o.y = 1.0 - s.y; }
case 3u: { o.z = 1.0 - s.z; }
default: { o = 1.0 - s; }
}
px = vec4<f32>((s + (o - s) * blend) * a, a);
}
}
case 7u: {
let c = P.f[0];
let invert = P.u[0].y != 0u;
let opacity = P.f[1].x;
var fa = px.w;
if (invert) {
fa = 1.0 - px.w;
}
let f = c.xyz * fa;
let rgb = px.xyz + (f - px.xyz) * opacity;
var alpha = px.w;
if (invert) {
alpha = px.w + (fa - px.w) * opacity;
}
px = vec4<f32>(rgb, alpha);
}
case 8u: {
// effects::color2::map_ca: transparent pixels only change through the alpha curve.
if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) {
let na = curve_eval(P.f[1].x, 0.0);
if (na > 0.0) {
px = vec4<f32>(0.0, 0.0, 0.0, clamp(na, 0.0, 1.0));
}
} else {
var c = vec3<f32>(0.0);
var aa = max(a, 0.0);
if (a > 1e-6) {
c = px.xyz / a;
aa = a;
}
var o = vec3<f32>(0.0);
for (var i = 0; i < 3; i++) {
o[i] = curve_eval(P.f[0][i + 1], curve_eval(P.f[0].x, c[i]));
}
let na = clamp(curve_eval(P.f[1].x, aa), 0.0, 1.0);
px = vec4<f32>(o * na, na);
}
}
case 9u: {
let s = P.f[0].xy;
let e = P.f[0].zw;
let d = e - s;
let v = vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5) - s;
let len2 = P.f[3].x;
var t: f32;
if (P.u[0].y != 0u) {
t = sqrt((v.x * v.x + v.y * v.y) / len2);
} else {
t = (v.x * d.x + v.y * d.y) / len2;
}
let scatter = P.f[3].y;
if (scatter > 0.0) {
t += (hash_noise(gid.x, gid.y, P.u[0].z) - 0.5) * scatter;
}
t = clamp(t, 0.0, 1.0);
let c0 = P.f[1].xyz;
let c1 = P.f[2].xyz;
px = put(px, c0 + (c1 - c0) * t, 1.0, P.f[3].z);
}
case 10u: {
let contrast = P.f[0].x;
let brightness = P.f[0].y;
let scale = P.f[0].z;
let frac = P.f[0].w;
let ox = P.f[1].x;
let oy = P.f[1].y;
let sr = P.f[1].z;
let cr = P.f[1].w;
let evo = P.f[2].x;
let kind = P.u[0].y;
let n_oct = P.u[0].z;
let seed = P.u[0].w;
let dx = f32(p.x) + 0.5 - ox;
let dy = f32(p.y) + 0.5 - oy;
let u = (dx * cr + dy * sr) / scale;
let v = (-dx * sr + dy * cr) / scale;
var sum = 0.0;
var amp = 1.0;
var norm = 0.0;
var f = 1.0;
for (var o = 0u; o < n_oct; o++) {
var w = 1.0;
if (o + 1u == n_oct && frac > 0.0) {
w = frac;
}
var n = value_noise(u * f, v * f, evo + f32(o) * 7.31, seed + o);
if (kind == 1u) {
n = 1.0 - abs(n * 2.0 - 1.0);
}
sum += n * amp * w;
norm += amp * w;
amp *= 0.5;
f *= 2.0;
}
var val = sum / max(norm, 1e-6);
val = clamp((val - 0.5) * contrast + 0.5 + brightness, 0.0, 1.0);
if (P.u[1].x != 0u) {
val = 1.0 - val;
}
px = put(px, vec3<f32>(val), P.f[2].z, P.f[2].y);
}
default: {}
}
textureStore(out, p, px);
}

517
crates/gpu/src/tests.rs Normal file
View File

@ -0,0 +1,517 @@
//! GPU vs CPU: the CPU renderer is the oracle. Every scene renders on both and must agree
//! within ≤ 1/255 at 8 bpc and ≤ 1e-3 at 16/32 bpc (per channel, premultiplied; relative for
//! over-range 32 bpc values above 1).
//!
//! Two documented exceptions, both from float rounding (the GPU computes in f32 with fast math,
//! the CPU's transforms in f64):
//! * 8/16 bpc quantise after every layer and effect, so a value within ~1e-6 of a rounding
//! boundary can land one step apart and later steps (Overlay's ×2 slope, colour-space curves)
//! can widen it: up to 0.3 % of pixels may differ by at most 4/255.
//! * Discontinuous modes (Hard Mix, Dissolve, Darker/Lighter Color) may flip pixels sitting
//! exactly on a threshold: up to 0.5 % of the frame.
//!
//! The tests skip (pass with a note) when no GPU adapter is available, as on headless CI.
use std::sync::{Arc, OnceLock};
use effectcraft_color::{BlendMode, ColorSpace, Label};
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{
AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, Layer, LayerSource, MaskMode, MatteKind, Project, Quality, Sampling, Solid, TrackMatte,
};
use effectcraft_raster::Image;
use effectcraft_render::{Backend, FootageSource, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::Gpu;
fn gpu() -> Option<&'static Gpu> {
static G: OnceLock<Option<Gpu>> = OnceLock::new();
G.get_or_init(|| {
let g = Gpu::headless();
if g.is_none() {
eprintln!("effectcraft-gpu tests: no GPU adapter, skipping GPU comparisons");
}
g
})
.as_ref()
}
/// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and
/// fully transparent regions (different per item).
struct Pattern;
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 {
for x in 0..w {
let (u, v) = (x as f32 / w as f32, y as f32 / h as f32);
let n = effectcraft_raster::hash_noise(x, y, seed) * 0.25;
let r = (0.5 + 0.5 * (u * 7.0 + k).sin()) * 0.75 + n;
let g = (0.5 + 0.5 * (v * 5.0 - k).cos()) * 0.75 + n * 0.5;
let b = ((u + v) * 0.5 + k).fract() * 0.8 + 0.1;
let d = ((u - 0.5).powi(2) + (v - 0.5).powi(2)).sqrt();
let a = ((0.48 - d) * 6.0).clamp(0.0, 1.0) * if (x / 9 + y / 7 + seed).is_multiple_of(5) { 0.6 } else { 1.0 };
img.set(x, y, [r.min(1.0) * a, g.min(1.0) * a, b * a, a]);
}
}
img
}
impl FootageSource for Pattern {
fn frame(&self, item: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
Some(Arc::new(pattern(item.0 as u32, f.width, f.height)))
}
}
struct Scene {
p: Project,
cid: ItemId,
comp: Comp,
}
impl Scene {
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));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
Scene { p, cid, comp }
}
fn footage(&mut self, w: u32, h: u32) -> Layer {
let f = Footage {
path: "pattern.png".into(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::ZERO,
has_video: true,
has_audio: false,
alpha: AlphaMode::Premultiplied,
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
};
let fid = self.p.add_item("Pattern", Label::Lavender, None, ItemKind::Footage(f));
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 {
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 {
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)]) {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = self.p.next_id;
let size = effectcraft_render::source_size(&self.p, l);
let mut g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [size.0 as f64, size.1 as f64]);
self.p.next_id = next;
for (k, v) in vals {
g.prop_mut(k).unwrap_or_else(|| panic!("{id}: no {k}")).value = v.clone();
}
l.props.sub_mut("effects").unwrap().children.push(g.into());
}
}
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 {
Value::Vec3([x, y, 0.0])
}
/// Differences of a GPU render against the CPU render.
#[derive(Debug)]
struct Diff {
max: f32,
/// Pixels over the tolerance.
over: usize,
total: usize,
worst: (u32, u32, [f32; 4], [f32; 4]),
/// 8/16 bpc render.
quantized: bool,
}
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() {
// Absolute below 1, relative above (32 bpc over-range values, e.g. Divide by ~0).
let m = (0..4).map(|c| (a[c] - b[c]).abs() / a[c].abs().max(1.0)).fold(0.0f32, f32::max);
let m = if m.is_nan() { f32::INFINITY } else { m };
if m > tol {
d.over += 1;
}
if m > d.max {
d.max = m;
d.worst = (i as u32 % cpu.width, i as u32 / cpu.width, *a, *b);
}
}
d
}
fn tolerance(depth: BitDepth) -> f32 {
match depth {
BitDepth::Bpc8 => 1.0 / 255.0 + 1e-6,
_ => 1e-3,
}
}
/// Render on the CPU and the GPU; `Some(diff)` (None without an adapter).
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 });
r.accel = Some(g);
let img = g.render(&r, s.cid, t).expect("the GPU renders the frame");
let mut d = diff(&cpu, &img, tolerance(s.p.settings.bit_depth));
d.quantized = s.p.settings.bit_depth != BitDepth::Bpc32;
Some(d)
}
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) {
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).
let (allow, cap) = if d.quantized && allow == 0.0 { (0.003, 4.0 / 255.0 + 1e-6) } else { (allow, f32::INFINITY) };
assert!(frac <= allow && d.max <= cap, "{label}: {} of {} pixels over tolerance (max {:.6}); worst {:?}", d.over, d.total, d.max, d.worst);
if d.over > 0 {
eprintln!("{label}: {} of {} pixels over tolerance (max {:.6})", d.over, d.total, d.max);
}
}
fn discontinuous(m: BlendMode) -> bool {
matches!(m, BlendMode::HardMix | BlendMode::Dissolve | BlendMode::DancingDissolve | BlendMode::DarkerColor | BlendMode::LighterColor)
}
/// Background, an Add layer (over-range values in 32 bpc) and a rotated, scaled top layer in
/// `mode` at 70 % opacity.
fn blend_scene(depth: BitDepth, mode: BlendMode) -> Scene {
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut add = s.footage(80, 50);
add.blend_mode = BlendMode::Add;
set(&mut add, "transform/opacity", Value::Scalar(50.0));
s.push(add);
let mut top = s.footage(64, 48);
top.blend_mode = mode;
set(&mut top, "transform/opacity", Value::Scalar(70.0));
set(&mut top, "transform/rotation", Value::Scalar(17.0));
set(&mut top, "transform/scale", Value::Vec3([115.0, 90.0, 100.0]));
set(&mut top, "transform/position", v3(52.3, 29.6));
s.push(top);
s
}
#[test]
fn all_blend_modes_at_every_bit_depth() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
for mode in BlendMode::ALL {
let s = blend_scene(depth, mode);
let allow = if discontinuous(mode) { 0.005 } else { 0.0 };
check(&format!("{mode:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow);
}
}
}
#[test]
fn transforms_sampling_and_resolution() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (i, (sampling, quality, scale, rot, sc)) in [
(Sampling::Bilinear, Quality::Best, 1.0, 0.0, 100.0),
(Sampling::Bilinear, Quality::Best, 1.0, 33.0, 140.0),
(Sampling::Bicubic, Quality::Best, 1.0, -21.0, 160.0),
(Sampling::Bilinear, Quality::Best, 1.0, 12.0, 30.0),
(Sampling::Bicubic, Quality::Best, 0.5, 45.0, 80.0),
(Sampling::Bilinear, Quality::Draft, 1.0, 0.0, 200.0),
(Sampling::Bilinear, Quality::Best, 0.25, 5.0, 100.0),
]
.into_iter()
.enumerate()
{
let mut s = Scene::new(depth);
let bg = s.solid([0.1, 0.2, 0.3], 97, 61);
s.push(bg);
let mut l = s.footage(90, 70);
l.switches.sampling = sampling;
l.switches.quality = quality;
set(&mut l, "transform/rotation", Value::Scalar(rot));
set(&mut l, "transform/scale", Value::Vec3([sc, sc * 0.9, 100.0]));
set(&mut l, "transform/position", v3(47.25, 31.5));
s.push(l);
check(&format!("transform #{i} {depth:?}"), compare_at(&s, RenderOpts { scale, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn masks_mattes_and_preserve_transparency() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for kind in [MatteKind::Alpha, MatteKind::AlphaInverted, MatteKind::Luma, MatteKind::LumaInverted] {
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut matte = s.footage(60, 40);
matte.switches.video = false;
set(&mut matte, "transform/rotation", Value::Scalar(-12.0));
let mut next = s.p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([30.0, 20.0], 50.0, 34.0), MaskMode::Add, [255, 255, 0]);
s.p.next_id = next;
matte.props.sub_mut("masks").unwrap().children.push(m.into());
let mid = s.push(matte);
let mut fill = s.footage(97, 61);
fill.track_matte = Some(TrackMatte { layer: mid, kind });
fill.blend_mode = BlendMode::Screen;
s.push(fill);
let mut pt = s.solid([0.9, 0.4, 0.1], 50, 30);
pt.preserve_transparency = true;
set(&mut pt, "transform/opacity", Value::Scalar(60.0));
s.push(pt);
check(&format!("matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
}
#[test]
fn motion_blur_accumulates_like_the_cpu() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
s.p.comp_mut(s.cid).unwrap().enable_motion_blur = true;
s.comp.enable_motion_blur = true;
let bg = s.solid([0.05, 0.05, 0.1], 97, 61);
s.push(bg);
let mut l = s.footage(30, 30);
l.switches.motion_blur = true;
l.blend_mode = BlendMode::Add;
let pos = l.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 20.0)), Keyframe::new(Tick::from_seconds_f64(0.5), v3(90.0, 45.0))];
let rot = l.props.prop_mut("transform/rotation").unwrap();
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(0.5), Value::Scalar(180.0))];
s.push(l);
check(&format!("motion blur {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.2)), 0.0);
}
}
#[test]
fn colour_management_and_linear_blending() {
for (ws, linearize, blend_linear) in
[(None, false, true), (Some(ColorSpace::Rec2020), false, false), (Some(ColorSpace::Srgb), true, false), (Some(ColorSpace::Rec709), false, true)]
{
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = blend_scene(depth, BlendMode::Overlay);
s.p.settings.working_space = ws;
s.p.settings.linearize = linearize;
s.p.settings.blend_linear = blend_linear;
check(&format!("colour {ws:?} {linearize} {blend_linear} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
}
#[test]
fn layer_styles_collapsed_precomps_and_cpu_fallbacks() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
// Layer styles (drop shadow pass + stroke body).
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut l = s.solid([0.8, 0.3, 0.2], 40, 30);
let mut next = s.p.next_id;
effectcraft_project::styles::add_style(&mut l, &mut Ids(&mut next), "dropShadow", &s.comp.global_light, true).unwrap();
effectcraft_project::styles::add_style(&mut l, &mut Ids(&mut next), "stroke", &s.comp.global_light, true).unwrap();
s.p.next_id = next;
l.blend_mode = BlendMode::Multiply;
set(&mut l, "transform/rotation", Value::Scalar(10.0));
s.push(l);
check(&format!("styles {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
// A collapsed precomp (nested blend modes act on the parent) and an adjustment layer
// (CPU fallback between GPU steps).
let mut s = Scene::new(depth);
let inner = Comp::new(60, 40, 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 (a, b) = {
let mut a = s.footage(60, 40);
a.blend_mode = BlendMode::Normal;
let mut b = s.solid([0.2, 0.7, 0.4], 30, 20);
b.blend_mode = BlendMode::Difference;
(a, b)
};
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a];
let bg = s.footage(97, 61);
s.push(bg);
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
pre.switches.collapse = true;
set(&mut pre, "transform/rotation", Value::Scalar(25.0));
set(&mut pre, "transform/opacity", Value::Scalar(80.0));
s.push(pre);
let mut adj = s.solid([1.0, 1.0, 1.0], 50, 61);
adj.switches.adjustment = true;
s.effect(&mut adj, "ec.channel.invert", &[]);
s.push(adj);
check(&format!("collapse + adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
/// 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) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
for scale in [1.0, 0.5] {
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.25, layer_size: size, seed: 11, adjustment, env: Default::default() };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
let d = diff(&cpu.img, &out.img, 1e-3);
assert!(d.over == 0, "{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}", d.over, d.total, d.max, d.worst);
}
}
/// One footage layer with `fx` applied, composited over a background, at 8 and 32 bpc.
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] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
s.effect(&mut l, id, vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
fn n(v: f64) -> Value {
Value::Scalar(v)
}
fn c(r: f64, g: f64, b: f64) -> Value {
Value::Color([r, g, b, 1.0])
}
#[test]
fn gpu_effects_match_cpu() {
effect_case("ec.blur.gaussian", &[("blurriness", n(9.0))]);
effect_case("ec.blur.gaussian", &[("blurriness", n(4.0)), ("dimensions", Value::Enum(1)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.fastbox", &[("radius", n(5.0)), ("iterations", n(2.0))]);
effect_case("ec.blur.fastbox", &[("radius", n(3.0)), ("dimensions", Value::Enum(2)), ("repeatEdge", Value::Bool(true))]);
effect_case("ec.blur.directional", &[("direction", n(30.0)), ("length", n(12.0))]);
effect_case("ec.stylize.glow", &[("threshold", n(40.0)), ("radius", n(14.0)), ("intensity", n(1.5))]);
effect_case("ec.stylize.glow", &[("colors", Value::Enum(1)), ("operation", Value::Enum(1)), ("colorA", c(1.0, 0.8, 0.1))]);
effect_case("ec.color.levels", &[("inBlack", n(0.1)), ("inWhite", n(0.8)), ("gamma", n(1.6)), ("outBlack", n(0.05))]);
effect_case("ec.color.levels", &[("inWhite", n(0.5)), ("noClip", Value::Bool(true))]);
effect_case(
"ec.color.curves",
&[("rgb", Value::Str("0,0 0.3,0.45 1,1".into())), ("red", Value::Str("0,0.1 1,0.9".into())), ("alpha", Value::Str("0,0 0.5,0.7 1,1".into()))],
);
effect_case("ec.color.huesaturation", &[("hue", n(70.0)), ("saturation", n(40.0)), ("lightness", n(-20.0))]);
effect_case("ec.color.huesaturation", &[("colorize", Value::Bool(true)), ("colorizeHue", n(200.0)), ("colorizeLightness", n(15.0))]);
effect_case("ec.color.tint", &[("black", c(0.1, 0.0, 0.3)), ("white", c(1.0, 0.9, 0.5)), ("amount", n(80.0))]);
effect_case("ec.generate.fill", &[("color", c(0.2, 0.6, 1.0)), ("opacity", n(70.0))]);
effect_case("ec.generate.fill", &[("invert", Value::Bool(true))]);
effect_case("ec.generate.gradientramp", &[("start", Value::Vec2([5.0, 3.0])), ("end", Value::Vec2([60.0, 40.0])), ("blend", n(25.0))]);
effect_case("ec.generate.gradientramp", &[("shape", Value::Enum(1)), ("scatter", n(40.0))]);
effect_case("ec.noise.fractal", &[]);
effect_case(
"ec.noise.fractal",
&[("fractalType", Value::Enum(1)), ("complexity", n(4.5)), ("rotation", n(30.0)), ("evolution", n(90.0)), ("scale", n(40.0)), ("blend", n(30.0))],
);
effect_case("ec.perspective.dropshadow", &[("distance", n(6.0)), ("softness", n(8.0)), ("opacity", n(80.0))]);
effect_case("ec.perspective.dropshadow", &[("shadowOnly", Value::Bool(true)), ("color", c(0.0, 0.2, 0.6))]);
effect_case("ec.color.brightnesscontrast", &[("brightness", n(20.0)), ("contrast", n(35.0))]);
effect_case("ec.color.brightnesscontrast", &[("brightness", n(-10.0)), ("contrast", n(-40.0))]);
effect_case("ec.color.exposure", &[("exposure", n(1.3)), ("offset", n(0.02)), ("gamma", n(1.2))]);
effect_case("ec.channel.invert", &[]);
effect_case("ec.channel.invert", &[("channel", Value::Enum(4)), ("blend", n(30.0))]);
effect_case("ec.channel.invert", &[("channel", Value::Enum(2))]);
effect_case("ec.distort.transform", &[("rotation", n(20.0)), ("scaleHeight", n(80.0)), ("skew", n(10.0)), ("opacity", n(70.0))]);
}
/// A chain of GPU effects (one upload, quantised between steps) and a non-GPU effect in the
/// middle (split into two GPU chains around the CPU effect).
#[test]
fn effect_chains_and_mixed_stacks() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.footage(80, 50);
s.effect(&mut l, "ec.color.exposure", &[("exposure", n(0.7))]);
s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]);
s.effect(&mut l, "ec.stylize.posterize", &[]);
s.effect(&mut l, "ec.stylize.glow", &[]);
s.effect(&mut l, "ec.channel.invert", &[("blend", n(50.0))]);
s.push(l);
check(&format!("chain {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
#[test]
fn registry_badges_match_the_gpu_implementation() {
for s in effectcraft_effects::registry() {
let implemented = crate::effects::supports(s.id);
assert_eq!(s.gpu, implemented, "{}: registry gpu = {}, GPU implementation = {implemented}", s.id, s.gpu);
}
}
#[test]
fn backend_selection_and_display_frames() {
let Some(g) = gpu() else { return };
let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen);
// Auto follows the project's Mercury GPU / Software Only setting; Cpu never uses the GPU.
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Auto, ..opts() });
r.accel = Some(g);
assert!(r.active_accel().is_some());
let mut p2 = s.p.clone();
p2.settings.gpu_acceleration = false;
let mut r2 = Renderer::new(&p2, &Pattern, RenderOpts { backend: Backend::Auto, ..opts() });
r2.accel = Some(g);
assert!(r2.active_accel().is_none());
let mut r3 = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() });
r3.accel = Some(g);
assert!(r3.active_accel().is_none());
// The display texture holds what the CPU viewer path shows (8-bit premultiplied).
let f = g.render_display(&r, s.cid, Tick::ZERO).expect("display frame");
let bytes = g.read_display(&f).expect("readback");
let cpu = Renderer::new(&s.p, &Pattern, opts()).comp_frame_cpu(s.cid, Tick::ZERO);
for (p, q) in cpu.data.iter().zip(bytes.chunks_exact(4)) {
let a = p[3].clamp(0.0, 1.0);
let want = [p[0].clamp(0.0, a), p[1].clamp(0.0, a), p[2].clamp(0.0, a), a].map(|v| (v * 255.0 + 0.5) as u8);
for k in 0..4 {
assert!((want[k] as i32 - q[k] as i32).abs() <= 1, "{want:?} vs {q:?}");
}
}
}

188
crates/gpu/src/walk.rs Normal file
View File

@ -0,0 +1,188 @@
//! 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.
use std::sync::Arc;
use effectcraft_color::BlendMode;
use effectcraft_effects::Buf;
use effectcraft_project::{ItemId, Layer, Quality};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, Renderer, styles};
use effectcraft_time::Tick;
use crate::context::{Enc, GpuImage};
use crate::ops;
/// Render a comp frame into a GPU image (the canvas in the output space, as
/// `Renderer::comp_frame_cpu` returns it). `None` when something cannot run here (the caller
/// renders on the CPU instead).
pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick) -> Option<GpuImage> {
let ctx = r.eval_ctx(comp_id, t)?;
let s = r.opts.scale;
let w = ((ctx.comp.width as f64 * s).round() as u32).max(1);
let h = ((ctx.comp.height as f64 * s).round() as u32).max(1);
if r.depth() > 16 || !e.g.fits(w, h) {
return None;
}
let mut canvas = e.image(w, h);
draw_comp(e, r, &ctx, &mut canvas)?;
let pipe = r.pipe();
if let Some(c) = pipe.from_blend() {
canvas = ops::convert(e, &canvas, &c);
}
if let Some(l) = pipe.levels {
canvas = ops::quantize(e, &canvas, l);
}
if r.depth() == 0
&& let Some(c) = pipe.output()
{
canvas = ops::convert(e, &canvas, &c);
if let Some(l) = pipe.levels {
canvas = ops::quantize(e, &canvas, l);
}
}
Some(canvas)
}
/// Run a CPU drawing step on the canvas (read back, draw, upload).
fn on_cpu(e: &mut Enc, canvas: &mut GpuImage, f: impl FnOnce(&mut Image)) -> Option<()> {
let mut img = e.download(canvas)?;
f(&mut img);
*canvas = e.g.upload_image(&img)?;
Some(())
}
fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut GpuImage) -> Option<()> {
let visible = r.visible_layers(ctx);
let levels = r.pipe().levels;
let mut i = 0;
while i < visible.len() {
if visible[i].is_3d() {
let mut j = i;
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
on_cpu(e, canvas, |img| r.draw_3d_run(ctx, &visible[i..j], img))?;
i = j;
} else {
match r.collapsed(ctx, visible[i]) {
Some(item) => draw_collapsed(e, r, ctx, visible[i], item, canvas)?,
None => draw_layer(e, r, ctx, visible[i], canvas)?,
}
i += 1;
}
// 8/16 bpc: the comp is an integer buffer after every layer.
if let Some(l) = levels {
*canvas = ops::quantize(e, canvas, l);
}
}
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 {
return Some(());
}
let matte = r.track_matte(ctx, layer).is_some();
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
if let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, opacity) {
draw_comp(e, &sub, &nctx, canvas)?;
}
return Some(());
}
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, 1.0) else { return Some(()) };
let mut iso = e.image(canvas.width, canvas.height);
draw_comp(e, &sub, &nctx, &mut iso)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
}
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.
return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img));
}
let opacity = r.layer_opacity(ctx, layer);
if opacity <= 0.0 {
return Some(());
}
let Some(st) = r.styled_layer(ctx, layer) else { return Some(()) };
if st.plain {
return composite_layer(e, r, ctx, layer, &st.body, canvas, opacity);
}
// Layer styles: exterior passes with their own modes, then the body; the layer's opacity
// fades the whole stack; knockout and channel switches as on the CPU.
let bl = styles::blending(ctx, layer);
let (w, h) = (canvas.width, canvas.height);
if r.track_matte(ctx, layer).is_some() || layer.preserve_transparency {
let mut iso = e.image(w, h);
for (b, m) in &st.passes {
iso = place(e, r, ctx, layer, b, &iso, *m, 1.0)?;
}
iso = place(e, r, ctx, layer, &st.body, &iso, BlendMode::Normal, 1.0)?;
return composite_iso(e, r, ctx, layer, iso, canvas, opacity);
}
let mut tmp = canvas.clone();
if bl.knockout > 0 {
let k = place(e, r, ctx, layer, &st.content, &e.image(w, h), BlendMode::Normal, 1.0)?;
tmp = ops::knockout(e, &tmp, &k);
}
for (b, m) in &st.passes {
tmp = place(e, r, ctx, layer, b, &tmp, *m, 1.0)?;
}
tmp = place(e, r, ctx, layer, &st.body, &tmp, layer.blend_mode, 1.0)?;
*canvas = ops::channel_mix(e, canvas, &tmp, bl.channels, 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 {
*canvas = place(e, r, ctx, layer, buf, canvas, layer.blend_mode, opacity)?;
return Some(());
}
// Render in isolation, then matte / preserve transparency, then blend.
let iso = place(e, r, ctx, layer, buf, &e.image(canvas.width, canvas.height), BlendMode::Normal, 1.0)?;
composite_iso(e, r, ctx, layer, iso, canvas, opacity)
}
/// Track matte / Preserve Transparency on an isolated layer render, then blend.
fn composite_iso(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, mut iso: GpuImage, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
if let Some((m, kind)) = r.track_matte(ctx, layer) {
let mut mimg = e.image(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = r.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
mimg = place(e, r, ctx, m, &mb, &mimg, BlendMode::Normal, mo)?;
}
iso = ops::matte(e, &iso, &mimg, kind);
}
if layer.preserve_transparency {
iso = ops::preserve(e, &iso, canvas);
}
*canvas = ops::blend_full(e, canvas, &iso, layer.blend_mode, opacity, layer.id.0 as u32);
Some(())
}
/// Draw a processed layer buffer onto `target` (transform, motion blur sub-samples).
fn place(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc<Buf>, target: &GpuImage, mode: BlendMode, opacity: f32) -> Option<GpuImage> {
if buf.img.is_empty() || opacity <= 0.0 {
return Some(target.clone());
}
let src = e.g.upload_buf(buf)?;
let pl = r.placement(ctx, layer, buf);
if let [m] = pl.matrices.as_slice() {
return Some(ops::warp(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None));
}
let mut acc = e.image(target.width, target.height);
let k = 1.0 / pl.matrices.len() as f32;
for m in &pl.matrices {
acc = ops::warp(e, &acc, &src, m, pl.sampling, BlendMode::Normal, 1.0, 0, Some(k));
}
Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed))
}

View File

@ -32,6 +32,7 @@ impl Exporter for FileExporter {
project: job.project,
footage: job.footage,
expr: job.expr,
accel: job.accel,
comp: job.item.comp,
settings: &job.item.settings,
output: &job.item.output,

View File

@ -113,6 +113,10 @@ pub struct ProjectSettings {
/// Frame numbering starts at 0 (or 1).
pub frame_start: i64,
pub audio_sample_rate: u32,
/// Video Rendering and Effects ▸ Use: Mercury GPU Acceleration (`true`, the default; used
/// when a GPU adapter exists) or Mercury Software Only (`false`, the CPU compositor).
#[serde(default = "yes")]
pub gpu_acceleration: bool,
}
impl Default for ProjectSettings {
@ -125,6 +129,7 @@ impl Default for ProjectSettings {
time_display: TimeDisplayStyle::Timecode,
frame_start: 0,
audio_sample_rate: 48_000,
gpu_acceleration: true,
}
}
}

View File

@ -128,6 +128,53 @@ impl FootageSource for NoFootage {
}
}
/// Which compositor renders a frame (Project Settings ▸ Video Rendering and Effects).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Backend {
/// The CPU reference compositor (Mercury Software Only).
#[default]
Cpu,
/// The GPU compositor whenever an [`Accelerator`] is attached, whatever the project says.
Gpu,
/// The GPU compositor when an [`Accelerator`] is attached and the project's renderer is
/// Mercury GPU Acceleration (the viewer's choice); the CPU otherwise.
Auto,
}
impl Backend {
pub fn parse(s: &str) -> Option<Backend> {
match s.to_ascii_lowercase().as_str() {
"cpu" | "software" | "mercury software only" => Some(Backend::Cpu),
"gpu" | "mercury gpu acceleration" => Some(Backend::Gpu),
"auto" => Some(Backend::Auto),
_ => None,
}
}
}
/// One effect of a GPU effect chain (see [`Accelerator::effects`]).
pub struct FxStep<'x> {
pub spec: &'static effectcraft_effects::EffectSpec,
pub ctx: EffectCtx<'x>,
}
/// A GPU (or other hardware) backend for the compositor. The CPU [`Renderer`] stays the
/// reference: an accelerator renders what it supports and returns `None` for the rest, which
/// then runs on the CPU. Implemented by `effectcraft-gpu`.
pub trait Accelerator: Send + Sync {
/// Adapter / backend name for status readouts ("Apple M2 (Metal)").
fn name(&self) -> String;
/// Render a top-level comp frame (what [`Renderer::comp_frame`] returns). `None` = not
/// handled (the CPU renders it).
fn comp_frame(&self, r: &Renderer, comp: ItemId, t: Tick) -> Option<Image>;
/// The accelerator implements effect `id` with the CPU effect's semantics.
fn supports_effect(&self, id: &str) -> bool;
/// Run a chain of supported effects on `buf`, clamping and quantising to `levels` after
/// every effect when set (8/16 bpc). `None` = not handled.
fn effects(&self, chain: &[FxStep], buf: &Buf, levels: Option<f32>) -> Option<Buf>;
}
#[derive(Clone, Copy, Debug)]
pub struct RenderOpts {
/// Output scale relative to comp pixels (1 = Full, 0.5 = Half…).
@ -139,11 +186,13 @@ pub struct RenderOpts {
pub draft: bool,
/// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera.
pub view: Option<three_d::CameraState>,
/// CPU or GPU compositor (the GPU needs [`Renderer::accel`]).
pub backend: Backend,
}
impl Default for RenderOpts {
fn default() -> Self {
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None }
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, backend: Backend::Cpu }
}
}
@ -156,6 +205,8 @@ pub struct Renderer<'a> {
pub cache: Option<&'a LayerCache>,
/// Optional per-layer timing sink (`frame --bench` / perf readouts).
pub profile: Option<&'a std::sync::Mutex<Vec<LayerTiming>>>,
/// GPU backend used when [`RenderOpts::backend`] asks for it.
pub accel: Option<&'a dyn Accelerator>,
/// Current nesting depth (precomp recursion guard).
depth: usize,
/// The project's colour pipeline (bit depth, working space, linear blending).
@ -204,6 +255,7 @@ impl<'a> Renderer<'a> {
opts,
cache: None,
profile: None,
accel: None,
depth: 0,
pipe: color::Pipe::of(&project.settings),
outer: None,
@ -220,7 +272,7 @@ impl<'a> Renderer<'a> {
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
/// styles (those force a flattened render of the precomp, as in After Effects).
pub(crate) fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
pub fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
return None;
}
@ -244,7 +296,7 @@ impl<'a> Renderer<'a> {
/// their transforms are concatenated with the precomp layer's (one resample, no
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
/// 3D layers use this comp's camera and lights.
pub(crate) fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
pub fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
let nc = self.project.comp(item)?;
let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.source_time(layer), ..*ctx };
let (l2c, _) = ctx.layer_to_comp(layer);
@ -336,8 +388,30 @@ impl<'a> Renderer<'a> {
EvalCtx { project: self.project, comp_id, comp, time: t, expr: self.expr }
}
/// The accelerator to use for this render, if any (see [`Backend`]).
pub fn active_accel(&self) -> Option<&'a dyn Accelerator> {
let a = self.accel?;
match self.opts.backend {
Backend::Cpu => None,
Backend::Gpu => Some(a),
Backend::Auto => self.project.settings.gpu_acceleration.then_some(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 {
if self.depth == 0
&& let Some(a) = self.active_accel()
&& let Some(img) = a.comp_frame(self, comp_id, t)
{
return img;
}
self.comp_frame_cpu(comp_id, t)
}
/// [`Self::comp_frame`] on the CPU compositor only.
pub fn comp_frame_cpu(&self, comp_id: ItemId, t: Tick) -> Image {
let Some(comp) = self.project.comp(comp_id) else { return Image::new(1, 1) };
let s = self.opts.scale;
let w = ((comp.width as f64 * s).round() as u32).max(1);
@ -364,15 +438,8 @@ impl<'a> Renderer<'a> {
/// Draw a comp's layers (bottom to top) into `canvas` (blending space).
fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image) {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
let visible: Vec<&Layer> = comp
.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect();
let visible = self.visible_layers(ctx);
let mut i = 0;
while i < visible.len() {
if visible[i].is_3d() {
@ -425,18 +492,49 @@ impl<'a> Renderer<'a> {
let host = FxHost { r: self, ctx, layer, index: Default::default() };
let env =
EffectEnv { masks: &mask_shapes, host: Some(&host), comp_time: ctx.time.seconds(), frame_rate: ctx.comp.frame_rate.as_f64(), effect_index: 0 };
for (i, g) in fx.groups().enumerate() {
if i >= limit {
break;
}
if !g.enabled {
continue;
}
let GroupKind::Effect { effect } = &g.kind else { continue };
let Some(spec) = effectcraft_effects::find(effect) else { continue };
if effectcraft_effects::audio_fx::is_audio_effect(spec.id) {
continue;
// Video effects in stack order (index, group, spec); disabled and audio effects skipped.
let stack: Vec<(usize, &effectcraft_project::PropGroup, &'static effectcraft_effects::EffectSpec)> = fx
.groups()
.enumerate()
.take_while(|(i, _)| *i < limit)
.filter(|(_, g)| g.enabled)
.filter_map(|(i, g)| match &g.kind {
GroupKind::Effect { effect } => effectcraft_effects::find(effect).map(|s| (i, g, s)),
_ => None,
})
.filter(|(_, _, s)| !effectcraft_effects::audio_fx::is_audio_effect(s.id))
.collect();
let accel = self.active_accel();
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.
if let Some(a) = accel
&& spec.gpu
&& a.supports_effect(spec.id)
{
let run: Vec<_> = stack[k..].iter().take_while(|(_, _, s)| s.gpu && a.supports_effect(s.id)).collect();
let params: Vec<Params> = run.iter().map(|(_, g, _)| self.effect_params(ctx, layer, g)).collect();
let steps: Vec<FxStep> = run
.iter()
.zip(&params)
.map(|((i, g, s), params)| FxStep {
spec: s,
ctx: EffectCtx { params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env: EffectEnv { effect_index: *i, ..env } },
})
.collect();
let t0 = web_time::Instant::now();
if let Some(out) = a.effects(&steps, &buf, self.pipe.levels) {
buf = out;
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)));
}
k += run.len();
continue;
}
}
k += 1;
let params = self.effect_params(ctx, layer, g);
host.index.store(i, std::sync::atomic::Ordering::Relaxed);
let env = EffectEnv { effect_index: i, ..env };
@ -574,7 +672,7 @@ impl<'a> Renderer<'a> {
}
/// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma).
pub(crate) fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
pub fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
let (b, k) = self.layer_buf_keyed(ctx, layer)?;
Some(self.to_blend(b, k))
}
@ -733,32 +831,45 @@ impl<'a> Renderer<'a> {
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
/// How a processed layer buffer lands in the output: one buffer → output matrix, or one per
/// motion-blur sub-sample (averaged), with the layer's sampling filter and dissolve seed.
pub fn placement(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Placement {
let samples = if self.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur {
if self.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
} else {
1
};
let opts = WarpOpts { sampling: self.sampling(layer), opacity, mode, seed: layer.id.0 as u32, clip: None };
let (sampling, seed) = (self.sampling(layer), layer.id.0 as u32);
if samples <= 1 {
let m = self.buf_matrix(ctx, layer, buf);
composite_warp(target, &buf.img, &m, &opts);
return;
return Placement { matrices: vec![self.buf_matrix(ctx, layer, buf)], sampling, seed };
}
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
let phase = ctx.comp.shutter_phase / 360.0;
let matrices = (0..samples)
.map(|i| {
let f = phase + angle * i as f64 / (samples - 1) as f64;
self.buf_matrix(&ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)), layer, buf)
})
.collect();
Placement { matrices, sampling, seed }
}
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) {
let pl = self.placement(ctx, layer, buf);
let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None };
if let [m] = pl.matrices.as_slice() {
composite_warp(target, &buf.img, m, &opts);
return;
}
// Sub-samples sum straight into one buffer (each warp is row-parallel).
let mut acc = Image::new(target.width, target.height);
let k = 1.0 / samples as f32;
for i in 0..samples {
let f = phase + angle * i as f64 / (samples - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let m = self.buf_matrix(&sub, layer, buf);
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, &m, opts.sampling, k);
let k = 1.0 / pl.matrices.len() as f32;
for m in &pl.matrices {
effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k);
}
target.blend_from(&acc, mode, opacity, layer.id.0 as u32);
target.blend_from(&acc, mode, opacity, pl.seed);
}
fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
@ -906,18 +1017,99 @@ impl<'a> Renderer<'a> {
}
}
/// A layer ready to composite (see [`Renderer::styled`]).
struct StyledLayer {
passes: Vec<(Arc<Buf>, BlendMode)>,
body: Arc<Buf>,
/// A layer ready to composite (see [`Renderer::styled_layer`]).
pub struct StyledLayer {
/// Exterior style passes (Drop Shadow, Outer Glow) with their blend modes.
pub passes: Vec<(Arc<Buf>, BlendMode)>,
pub body: Arc<Buf>,
/// Pre-style pixels (knockout shape).
content: Arc<Buf>,
pub content: Arc<Buf>,
/// Styled cache key (the content key when plain).
key: Option<u64>,
/// Content cache key.
ckey: Option<u64>,
/// No layer styles: composite `body` the usual way.
plain: bool,
pub plain: bool,
}
/// Where a layer buffer lands (see [`Renderer::placement`]).
#[derive(Clone, Debug)]
pub struct Placement {
/// Buffer pixel → output pixel; several = motion-blur sub-samples (equal weights).
pub matrices: Vec<Mat3>,
pub sampling: effectcraft_raster::Sampling,
/// Dissolve noise seed.
pub seed: u32,
}
/// Hooks for accelerated compositors (`effectcraft-gpu`): the pieces of the CPU walk they reuse
/// or fall back to. The CPU compositor itself is [`Renderer::comp_frame_cpu`].
impl<'a> Renderer<'a> {
/// The project's colour pipeline.
pub fn pipe(&self) -> color::Pipe {
self.pipe
}
/// Nesting depth (0 = the top-level comp).
pub fn depth(&self) -> usize {
self.depth
}
/// Evaluation context of comp `comp_id` at comp time `t`.
pub fn eval_ctx(&self, comp_id: ItemId, t: Tick) -> Option<EvalCtx<'a>> {
Some(self.ctx(comp_id, self.project.comp(comp_id)?, t))
}
/// The layers drawn at the context time, bottom to top (solo, guides and visibility
/// applied).
pub fn visible_layers(&self, ctx: &EvalCtx<'a>) -> Vec<&'a Layer> {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
comp.layers
.iter()
.rev()
.filter(|l| l.is_active_at(t) && l.has_video() && (!any_solo || l.switches.solo) && (self.opts.guides || !l.switches.guide))
.collect()
}
/// Draw a run of consecutive 3D layers into `canvas` (CPU).
pub fn draw_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], canvas: &mut Image) {
three_d::compose::draw_run(self, ctx, run, canvas);
}
/// 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);
}
/// Draw a collapsed precomp layer into `canvas` on the CPU.
pub fn draw_collapsed_cpu(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) {
self.draw_collapsed(ctx, layer, item, canvas);
}
/// The layer's finished buffers (styles included) in the blending space.
pub fn styled_layer(&self, ctx: &EvalCtx, layer: &Layer) -> Option<StyledLayer> {
let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() });
let t0 = web_time::Instant::now();
let st = self.styled(ctx, layer, timing.as_mut()).map(|st| self.styled_to_blend(st));
if let (Some(prof), Some(mut timing)) = (self.profile, timing) {
timing.process_ms = t0.elapsed().as_secs_f64() * 1e3;
if let Ok(mut v) = prof.lock() {
v.push(timing);
}
}
st
}
/// Opacity the layer composites with (its Opacity × collapsed precomps' opacity).
pub fn layer_opacity(&self, ctx: &EvalCtx, layer: &Layer) -> f32 {
ctx.opacity(layer) as f32 * self.opacity_mul
}
/// The layer's track matte layer and kind, if it has a valid one.
pub fn track_matte<'c>(&self, ctx: &EvalCtx<'c>, layer: &Layer) -> Option<(&'c Layer, MatteKind)> {
layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)))
}
}
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.

View File

@ -319,7 +319,14 @@ impl EffectcraftApp {
let Some(c) = self.session.project.comp(comp) else { return };
let key = self.frame_key(comp, frame, scale);
let t = c.frame_rate.tick_of(frame);
let opts = RenderOpts { scale, motion_blur: true, guides: true, draft: self.ui.viewer.fast_preview, view: self.session.view_camera(comp) };
let opts = RenderOpts {
scale,
motion_blur: true,
guides: true,
draft: self.ui.viewer.fast_preview,
view: self.session.view_camera(comp),
backend: effectcraft_engine::render::Backend::Auto,
};
if urgent {
self.frames.request_urgent(&self.render_source(), key, comp, t, opts);
} else {

View File

@ -27,6 +27,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, frame cache |
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
| L3 | `export` | Render queue encoding: H.264, ProRes, PNG/JPEG/TIFF/EXR sequences, GIF, audio |
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus |
| L4 | `host` | A fully wired `Session` (media, expressions, exporter) for the frontends |
@ -35,7 +36,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) |
Allowed same-layer edges: `path → keyframe, raster`, `text → path`, `effects → project, text, path`,
`media / expr / export → render`, `export → media`, `lottie → format`, `host → engine`.
`media / expr / export / gpu → render`, `export → media`, `lottie → format`, `host → engine`.
## 2. Time
@ -103,6 +104,27 @@ groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `pup
Half, Third and Quarter resolution render proportionally fewer pixels end to end.
**GPU compositor** (`crates/gpu`; Project Settings ▸ Video Rendering and Effects ▸ Mercury GPU
Acceleration, the default, or Mercury Software Only; `render.backend`). The CPU renderer is the
reference and keeps rendering layer content (sources, masks, CPU effects, layer styles) into the
layer cache. The render crate defines an `Accelerator` trait; `effectcraft_gpu::Gpu` implements it
on wgpu compute shaders. `RenderOpts::backend` picks `Cpu`, `Gpu` or `Auto` (GPU when an
accelerator is attached and the project's renderer is the GPU). A GPU frame walks the comp like
`draw_comp`: cached layer buffers are uploaded once per buffer, then transformed with the CPU's
sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur
sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes,
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
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.
**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
after every effect, and the comp after every layer (16 bpc uses 0..32768), so over-range values

View File

@ -69,11 +69,11 @@ rendering, on-canvas text editing) are where the conservative figure is most lik
| Text | 63% | 5.8 | on-canvas editing with a cursor, per-character styles, paragraph spacing and indents |
| Web | 60% | 2.5 | threads, browser storage, audio |
| 3D | 58% | 20.5 | Advanced 3D (models, PBR, image-based light), 3D camera tracker, multi-view layouts |
| Effects | 56% | 14.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU effects, 41 missing effects |
| Effects | 58% | 13.6 | Effect Controls widgets (angle dial, point crosshair, eyedropper, curves), GPU versions of more effects, 41 missing effects |
| Interface | 55% | 10.4 | drag-to-dock and floating panels, viewer rulers/snapping/channels/snapshots, preferences, native macOS menus |
| Project | 53% | 8.1 | 8/16/32-bit pipeline and colour management, auto-save, proxies, folder moves |
| Masks & roto | 44% | 13.0 | mask tracking, Roto Brush, free transform and RotoBezier |
| Preview | 43% | 8.1 | GPU compositor, disk cache, region of interest, snapshots and exposure |
| Preview | 55% | 6.1 | GPU 3D and adjustment layers, disk cache, region of interest, snapshots and exposure |
| Tracking | 0% | 13.0 | point tracker, Warp Stabilizer, mask and face tracking |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -90,7 +90,8 @@ Mocha and Cineware are third-party and not counted.
1. On-canvas text editing and per-character styles.
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.
4. A GPU (wgpu) compositor, then GPU effects.
4. ~~A GPU (wgpu) compositor, then GPU effects~~ (done: 2D compositing and 16 GPU effects; 3D runs
and adjustment layers still composite on the CPU).
5. Pen tool for shape paths, shape vertex editing, the Layer viewer, free transform and RotoBezier.
6. Motion-path handles in the viewer; graph editor transform box and snapping.
7. Point tracking and stabilization (in progress).