Files
effectcraft/crates/gpu/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 6eedf8e2c5 GPU on wgpu's OpenGL backend: renderable depth target, portable shaders, serialised tests
FreeBSD's CI VM (Mesa llvmpipe over GL) failed 22 effectcraft-gpu tests:

- Advanced 3D's camera-depth target was R32Float, which GLES can't render to without
  EXT_color_buffer_float: create_render_pipeline failed validation (release builds only log
  that and draw nothing). The target is now R32Uint holding the f32 bits (renderable on every
  backend, lossless), and GpuContext checks the adapter's format features (render attachment,
  blendable colour) and independent blending up front; when they're missing the render
  pipelines are never built and Advanced 3D renders on the CPU.
- Signed `%` in the shaders: naga emits GLSL `%`, undefined for negative operands, which broke
  the VR effects' horizontal wrap and Cell Pattern's tiling. A shared `imod` never takes `%` of
  a negative number.
- asin/acos: Mesa's are coarse approximations (~1e-4 rad), visible in every lens/sphere/cylinder
  mapping. `asin_p`/`acos_p` (Cephes' asinf polynomial) are f32-accurate on every backend.
- "Could not lock adapter context": the GL backend has one context lock per adapter with a
  timeout, so tests driving devices concurrently panic. Every gpu test helper that hands out a
  device holds one process-wide lock for the calling test; ui_web_job_gpu's two device tests
  share a lock too.
- On GL, up to 1 % of pixels may flip across a decision boundary (division through a
  reciprocal, unfused fma); the GPU-vs-CPU comparisons allow that there only.

Also adds the FreeBSD workflow (from the FreeBSD CI PR) so this branch is checked there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 10:08:21 -04:00

653 lines
30 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;
/// 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: &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);
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);
}