667 lines
31 KiB
Rust
667 lines
31 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;
|
||
|
||
/// One GPU test at a time in this process: the calling test thread holds a process-wide lock
|
||
/// until it exits (libtest runs every test on a thread of its own). wgpu's OpenGL backend (Mesa
|
||
/// llvmpipe on FreeBSD, Linux without Vulkan) has one context lock per adapter and panics when a
|
||
/// thread waits on it for more than a few seconds ("Could not lock adapter context"), and
|
||
/// concurrent devices on llvmpipe can crash. Every test helper that hands out a device calls this
|
||
/// first; calling it again on the same thread is a no-op.
|
||
pub(crate) fn hold_gpu_lock() {
|
||
use std::cell::RefCell;
|
||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||
static GPU_LOCK: Mutex<()> = Mutex::new(());
|
||
thread_local! {
|
||
static HELD: RefCell<Option<MutexGuard<'static, ()>>> = const { RefCell::new(None) };
|
||
}
|
||
HELD.with(|h| {
|
||
let mut h = h.borrow_mut();
|
||
if h.is_none() {
|
||
*h = Some(GPU_LOCK.lock().unwrap_or_else(PoisonError::into_inner));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The shared test device (the calling test holds [`hold_gpu_lock`]).
|
||
pub(crate) fn gpu() -> Option<&'static Gpu> {
|
||
static G: OnceLock<Option<Gpu>> = OnceLock::new();
|
||
hold_gpu_lock();
|
||
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()
|
||
}
|
||
|
||
/// The test device is on wgpu's OpenGL backend (Mesa's llvmpipe on the FreeBSD CI job).
|
||
pub(crate) fn on_gl() -> bool {
|
||
gpu().is_some_and(|g| g.ctx.name.ends_with("(Gl)"))
|
||
}
|
||
|
||
/// On GL, the fraction of pixels allowed past the tolerance: pixels on a decision boundary (a
|
||
/// hex cell's edge, a threshold, a corner pin's coverage edge, a median's rank tie) may land on
|
||
/// the other side, since GL drivers may divide through a reciprocal and GLSL's `fma` need not be
|
||
/// fused (so `fxs_qdiv`-style corrections don't apply). Measured on llvmpipe: at most 0.53 % (CC
|
||
/// HexTile at half resolution, many cell edges in a small frame). A wrong wrap (signed `%`) showed
|
||
/// in 3–70 % of pixels and still fails; a precision loss may show in fewer (Mesa's `asin`: 0.3–3 %),
|
||
/// so the shaders avoid imprecise builtins themselves (`asin_p`) rather than lean on this.
|
||
pub(crate) const GL_BOUNDARY_FLIPS: f64 = 0.01;
|
||
|
||
/// 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).
|
||
// A NaN on one side only (e.g. a kernel that skipped pixels of a NaN-poisoned scratch
|
||
// image) is the largest difference.
|
||
let m = (0..4)
|
||
.map(|c| match (a[c].is_nan(), b[c].is_nan()) {
|
||
(true, true) => 0.0,
|
||
(false, false) => (a[c] - b[c]).abs() / a[c].abs().max(1.0),
|
||
_ => f32::INFINITY,
|
||
})
|
||
.fold(0.0f32, f32::max);
|
||
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) };
|
||
let (allow, cap) = if on_gl() { (allow.max(GL_BOUNDARY_FLIPS), f32::INFINITY) } else { (allow, cap) };
|
||
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);
|
||
let allow = if on_gl() { (d.total as f64 * GL_BOUNDARY_FLIPS) as usize } else { 0 };
|
||
assert!(
|
||
d.over <= allow,
|
||
"{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.ccplastic", &[]);
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Browsers' WGSL compilers reject an f32 literal whose exact decimal value lies outside the f32
|
||
/// range (`3.40282347e38` rounds to f32::MAX in Rust but exceeds it), which invalidates the whole
|
||
/// module (every kernel); naga accepts it, so native runs can't catch it. Every float literal
|
||
/// with an exponent must be within the f32 range exactly.
|
||
#[test]
|
||
fn wgsl_float_literals_fit_f32() {
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders");
|
||
let mut checked = 0;
|
||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||
let path = entry.unwrap().path();
|
||
let src = std::fs::read_to_string(&path).unwrap();
|
||
for (n, line) in src.lines().enumerate() {
|
||
let code = line.split("//").next().unwrap_or("");
|
||
let bytes = code.as_bytes();
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
let starts = bytes[i].is_ascii_digit() && (i == 0 || !(bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_' || bytes[i - 1] == b'.'));
|
||
if !starts {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let mut j = i;
|
||
while j < bytes.len()
|
||
&& (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'.' || ((bytes[j] == b'-' || bytes[j] == b'+') && matches!(bytes[j - 1], b'e' | b'E')))
|
||
{
|
||
j += 1;
|
||
}
|
||
let lit = code[i..j].trim_end_matches('f');
|
||
if !lit.starts_with("0x")
|
||
&& lit.contains(['e', 'E'])
|
||
&& let Ok(v) = lit.parse::<f64>()
|
||
{
|
||
checked += 1;
|
||
assert!(v.abs() <= f32::MAX as f64, "{}:{}: {lit} is outside the f32 range", path.display(), n + 1);
|
||
}
|
||
i = j;
|
||
}
|
||
}
|
||
}
|
||
assert!(checked > 0);
|
||
}
|
||
|
||
/// A device created with WebGL2's limits (what egui-wgpu asks for on GL, as the desktop app on
|
||
/// FreeBSD gets): the compositor declines it instead of building pipelines that fail validation
|
||
/// (wgpu's default error handler panics here; release builds would only log it).
|
||
#[test]
|
||
fn devices_with_webgl2_limits_are_declined() {
|
||
hold_gpu_lock();
|
||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { return };
|
||
let desc = wgpu::DeviceDescriptor { required_limits: wgpu::Limits::downlevel_webgl2_defaults(), ..Default::default() };
|
||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&desc)) else { return };
|
||
let err = crate::context::GpuContext::new(&adapter, device, queue).err().expect("declined");
|
||
assert!(err.contains("max_storage_buffers_per_shader_stage"), "{err}");
|
||
}
|