effectcraft/crates/gpu/src/tests.rs

558 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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: &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.
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:?}");
}
}
}