mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-23 12:13:04 -05:00
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>
168 lines
5.9 KiB
Rust
168 lines
5.9 KiB
Rust
//! 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
|
|
}
|