558 lines
25 KiB
Rust
558 lines
25 KiB
Rust
//! 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;
|
||
|
||
pub(crate) 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).
|
||
pub(crate) struct Pattern;
|
||
|
||
pub(crate) fn pattern(seed: u32, w: u32, h: u32) -> Image {
|
||
let mut img = Image::new(w, h);
|
||
let k = seed as f32 * 0.37;
|
||
for y in 0..h {
|
||
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)))
|
||
}
|
||
}
|
||
|
||
pub(crate) struct Scene {
|
||
pub(crate) p: Project,
|
||
pub(crate) cid: ItemId,
|
||
pub(crate) comp: Comp,
|
||
}
|
||
|
||
impl Scene {
|
||
pub(crate) fn new(depth: BitDepth) -> Scene {
|
||
let mut p = Project::default();
|
||
p.settings.bit_depth = depth;
|
||
let comp = Comp::new(97, 61, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||
Scene { p, cid, comp }
|
||
}
|
||
|
||
pub(crate) 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,
|
||
..Default::default()
|
||
};
|
||
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)
|
||
}
|
||
|
||
pub(crate) fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer {
|
||
let sid = self.p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
|
||
build::layer(&mut self.p, &self.comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
|
||
}
|
||
|
||
/// Add on top of the stack.
|
||
pub(crate) fn push(&mut self, l: Layer) -> effectcraft_project::LayerId {
|
||
let id = l.id;
|
||
self.p.comp_mut(self.cid).unwrap().layers.insert(0, l);
|
||
id
|
||
}
|
||
|
||
pub(crate) fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) {
|
||
let spec = effectcraft_effects::find(id).unwrap();
|
||
let mut next = self.p.next_id;
|
||
let size = effectcraft_render::source_size(&self.p, l);
|
||
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());
|
||
}
|
||
}
|
||
|
||
pub(crate) fn set(l: &mut Layer, path: &str, v: Value) {
|
||
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
|
||
}
|
||
|
||
pub(crate) fn v3(x: f64, y: f64) -> Value {
|
||
Value::Vec3([x, y, 0.0])
|
||
}
|
||
|
||
/// Differences of a GPU render against the CPU render.
|
||
#[derive(Debug)]
|
||
pub(crate) struct Diff {
|
||
pub(crate) max: f32,
|
||
/// Pixels over the tolerance.
|
||
pub(crate) over: usize,
|
||
pub(crate) total: usize,
|
||
pub(crate) worst: (u32, u32, [f32; 4], [f32; 4]),
|
||
/// 8/16 bpc render.
|
||
pub(crate) quantized: bool,
|
||
}
|
||
|
||
pub(crate) fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff {
|
||
assert_eq!((cpu.width, cpu.height), (gpu.width, gpu.height), "size");
|
||
let mut d = Diff { max: 0.0, over: 0, total: cpu.data.len(), worst: (0, 0, [0.0; 4], [0.0; 4]), quantized: false };
|
||
for (i, (a, b)) in cpu.data.iter().zip(&gpu.data).enumerate() {
|
||
// 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
|
||
}
|
||
|
||
pub(crate) 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).
|
||
pub(crate) fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option<Diff> {
|
||
let g = gpu()?;
|
||
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t);
|
||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts });
|
||
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)
|
||
}
|
||
|
||
pub(crate) fn opts() -> RenderOpts {
|
||
RenderOpts { motion_blur: true, ..Default::default() }
|
||
}
|
||
|
||
/// Assert agreement; `allow` = fraction of pixels allowed over the tolerance.
|
||
pub(crate) fn check(label: &str, d: Option<Diff>, allow: f64) {
|
||
let Some(d) = d else { return };
|
||
let frac = d.over as f64 / d.total as f64;
|
||
// Quantised depths: rounding-boundary flips (see the module docs).
|
||
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).
|
||
pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) {
|
||
let Some(g) = gpu() else { return };
|
||
let spec = effectcraft_effects::find(id).unwrap();
|
||
let size = [70.0, 44.0];
|
||
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: ¶ms, 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.
|
||
pub(crate) fn effect_case(id: &str, vals: &[(&str, Value)]) {
|
||
effect_direct(id, vals, false);
|
||
effect_direct(id, vals, true);
|
||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||
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);
|
||
}
|
||
}
|
||
|
||
pub(crate) fn n(v: f64) -> Value {
|
||
Value::Scalar(v)
|
||
}
|
||
|
||
pub(crate) 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.stylize.glow",
|
||
&[
|
||
("colors", Value::Enum(1)),
|
||
("colorLooping", Value::Enum(3)),
|
||
("colorLoops", n(2.5)),
|
||
("colorPhase", n(40.0)),
|
||
("abMidpoint", n(30.0)),
|
||
("glowDimensions", Value::Enum(1)),
|
||
],
|
||
);
|
||
effect_case("ec.stylize.glow", &[("based", Value::Enum(0)), ("operation", Value::Enum(2)), ("threshold", n(20.0))]);
|
||
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.levels", &[("inBlack", n(0.2)), ("inWhite", n(0.6)), ("clipToOutputBlack", Value::Enum(0))]);
|
||
effect_case("ec.color.levels", &[("inBlack", n(0.2)), ("inWhite", n(0.6)), ("clipToOutputWhite", Value::Enum(0))]);
|
||
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)),
|
||
("transform/rotation", n(30.0)),
|
||
("evolution", n(90.0)),
|
||
("transform/scale", n(40.0)),
|
||
("blend", n(30.0)),
|
||
],
|
||
);
|
||
effect_case(
|
||
"ec.noise.fractal",
|
||
&[
|
||
("fractalType", Value::Enum(2)),
|
||
("overflow", Value::Enum(1)),
|
||
("contrast", n(250.0)),
|
||
("transform/uniformScaling", Value::Bool(false)),
|
||
("transform/scaleWidth", n(60.0)),
|
||
("transform/scaleHeight", n(25.0)),
|
||
("subSettings/subInfluence", n(40.0)),
|
||
("subSettings/subScaling", n(70.0)),
|
||
("opacity", n(60.0)),
|
||
],
|
||
);
|
||
effect_case("ec.noise.fractal", &[("overflow", Value::Enum(2)), ("contrast", n(300.0)), ("blendingMode", Value::Enum(0)), ("opacity", n(50.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.brightnesscontrast", &[("brightness", n(20.0)), ("contrast", n(35.0)), ("useLegacy", Value::Bool(true))]);
|
||
effect_case("ec.color.exposure", &[("master/exposure", n(1.3)), ("master/offset", n(0.02)), ("master/gamma", n(1.2))]);
|
||
effect_case("ec.color.exposure", &[("channels", Value::Enum(1)), ("red/redExposure", n(0.8)), ("green/greenOffset", n(-0.05)), ("blue/blueGamma", n(1.6))]);
|
||
effect_case("ec.color.exposure", &[("master/exposure", n(0.5)), ("master/offset", n(-0.03)), ("bypassLinearLight", Value::Bool(true))]);
|
||
effect_case("ec.channel.invert", &[]);
|
||
effect_case("ec.channel.invert", &[("channel", Value::Enum(effectcraft_effects::INVERT_ALPHA)), ("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", &[("master/exposure", n(0.7))]);
|
||
s.effect(&mut l, "ec.blur.gaussian", &[("blurriness", n(5.0))]);
|
||
s.effect(&mut l, "ec.stylize.coloremboss", &[]);
|
||
s.effect(&mut l, "ec.stylize.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:?}");
|
||
}
|
||
}
|
||
}
|