Merge remote-tracking branch 'origin/main' into fix/environment-light-no-effect-cast-shadows

This commit is contained in:
Z User
2026-10-27 08:54:33 +00:00
488 changed files with 42290 additions and 40792 deletions
+139
View File
@@ -0,0 +1,139 @@
//! Adjustment layers on the GPU vs the CPU: GPU-only stacks (resident on the device), mixed
//! stacks (CPU effects in between read back and upload), masks, opacity, colour management.
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, ColorSpace, MaskMode, MatteKind, TrackMatte};
use effectcraft_render::{Backend, FxStep, FxTarget, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::{Pattern, Scene, c, check, compare_at, gpu, n, opts, set};
fn adjustment_scene(depth: BitDepth, stack: &[(&str, Vec<(&str, Value)>)], masked: bool) -> Scene {
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut l = s.footage(60, 40);
set(&mut l, "transform/rotation", Value::Scalar(15.0));
s.push(l);
let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50);
adj.switches.adjustment = true;
set(&mut adj, "transform/opacity", Value::Scalar(75.0));
set(&mut adj, "transform/rotation", Value::Scalar(-6.0));
for (id, vals) in stack {
s.effect(&mut adj, id, vals);
}
if masked {
let mut next = s.p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 25.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]);
s.p.next_id = next;
adj.props.sub_mut("masks").unwrap().children.push(m.into());
}
s.push(adj);
let top = s.solid([0.2, 0.6, 0.9], 20, 20);
s.push(top);
s
}
fn gpu_stack() -> Vec<(&'static str, Vec<(&'static str, Value)>)> {
vec![
("ec.blur.gaussian", vec![("blurriness", n(6.0))]),
("ec.color.levels", vec![("inBlack", n(0.1)), ("gamma", n(1.4))]),
("ec.color.tint", vec![("black", c(0.1, 0.0, 0.2)), ("amount", n(60.0))]),
]
}
#[test]
fn gpu_adjustment_chains_match_the_cpu() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
for masked in [false, true] {
let s = adjustment_scene(depth, &gpu_stack(), masked);
check(&format!("gpu adjustment {depth:?} masked {masked}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("gpu adjustment {depth:?} masked {masked} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn mixed_adjustment_chains_fall_back_per_effect() {
let mut stack = gpu_stack();
stack.insert(1, ("ec.distort.rollingshutterrepair", vec![]));
stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))]));
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let s = adjustment_scene(depth, &stack, true);
check(&format!("mixed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
// Colour-managed: effects run in the working space, compositing in linear light.
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = adjustment_scene(depth, &stack, false);
s.p.settings.working_space = Some(ColorSpace::Rec709);
s.p.settings.blend_linear = true;
check(&format!("colour-managed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
/// A track matte limits where the adjustment shows, as on the CPU (#1201).
#[test]
fn track_mattes_limit_gpu_adjustments_like_the_cpu() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for kind in [MatteKind::Alpha, MatteKind::AlphaInverted, MatteKind::Luma, MatteKind::LumaInverted] {
let mut s = adjustment_scene(depth, &gpu_stack(), false);
let mut matte = s.footage(50, 30);
matte.switches.video = false;
set(&mut matte, "transform/rotation", Value::Scalar(10.0));
let mid = s.push(matte);
let c = s.p.comp_mut(s.cid).unwrap();
let adj = c.layers.iter_mut().find(|l| l.switches.adjustment).unwrap();
adj.track_matte = Some(TrackMatte { layer: mid, kind });
check(&format!("matted adjustment {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
}
/// Counts where each run of the stack goes.
#[derive(Default)]
struct Probe {
gpu: usize,
cpu: usize,
}
impl FxTarget for Probe {
fn gpu(&mut self, steps: &[FxStep]) -> bool {
self.gpu += steps.len();
true
}
fn cpu(&mut self, _f: &mut dyn FnMut(effectcraft_effects::Buf) -> effectcraft_effects::Buf) {
self.cpu += 1;
}
}
#[test]
fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() {
let Some(g) = gpu() else { return };
let mut stack = gpu_stack();
stack.insert(1, ("ec.generate.fractal", vec![]));
let s = adjustment_scene(BitDepth::Bpc32, &stack, false);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(g);
let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap();
let adj = ctx.comp.layers.iter().find(|l| l.switches.adjustment).unwrap();
let mut p = Probe::default();
r.run_effects_on(&ctx, adj, true, &mut p);
assert_eq!((p.gpu, p.cpu), (3, 1));
}
#[test]
fn composite_reads_the_original_adjustment_input_on_gpu() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for mode in [0, 1, 2, 4, 15] {
for rgb in [false, true] {
let stack = vec![
("ec.channel.invert", vec![]),
("ec.channel.cccomposite", vec![("compositeOriginal", Value::Enum(mode)), ("opacity", n(65.0)), ("rgbOnly", Value::Bool(rgb))]),
];
let s = adjustment_scene(depth, &stack, true);
check(&format!("original adjustment {depth:?} mode {mode} rgb {rgb}"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
}
+624
View File
@@ -0,0 +1,624 @@
//! Advanced 3D: the GPU rasteriser against the CPU reference on the same scenes.
//!
//! Tolerances: the GPU's colour target is half float (relative error ≤ 1e-3) and its
//! rasteriser and interpolators differ from the CPU's in the last bits, so pixels on triangle
//! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most
//! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter.
use effectcraft_keyframe::{Gradient, Value};
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid};
use effectcraft_render::three_d::adv::{self, Scene};
use effectcraft_render::{Accelerator, EvalCtx, NoFootage, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::Gpu;
/// A device of the test's own (the test holds [`crate::tests::hold_gpu_lock`]).
fn gpu() -> Option<Gpu> {
crate::tests::hold_gpu_lock();
match Gpu::try_headless() {
Ok(g) => Some(g),
Err(e) => {
eprintln!("effectcraft-gpu adv3d tests: no GPU ({e}), skipping");
crate::tests::require_gpu("effectcraft-gpu adv3d tests", Err(&e))
}
}
}
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
let comp = p.comp(cid).unwrap().clone();
let mut l = build::layer(p, &comp, "L", src, (comp.width, comp.height), None);
edit(&mut l);
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v;
}
/// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light,
/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent
/// textured card.
pub(crate) fn scene_project() -> (Project, ItemId) {
let mut p = Project::default();
let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(900.0));
set(l, "geometryOptions/height", Value::Scalar(900.0));
set(l, "transform/position", Value::Vec3([120.0, 130.0, 0.0]));
set(l, "transform/rotationX", Value::Scalar(90.0));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(35.0));
set(l, "transform/position", Value::Vec3([80.0, 95.0, 0.0]));
set(l, "materialOptions/baseColor", Value::Color([0.9, 0.2, 0.2, 1.0]));
set(l, "materialOptions/roughness", Value::Scalar(35.0));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Torus }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(35.0));
set(l, "geometryOptions/tubeRadius", Value::Scalar(12.0));
set(l, "transform/position", Value::Vec3([165.0, 90.0, 0.0]));
set(l, "transform/rotationX", Value::Scalar(60.0));
set(l, "materialOptions/metallic", Value::Scalar(100.0));
set(l, "materialOptions/roughness", Value::Scalar(20.0));
set(l, "materialOptions/castsShadows", Value::Enum(1));
});
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.6, 0.9], width: 60, height: 40, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| {
l.switches.three_d = true;
set(l, "transform/opacity", Value::Scalar(60.0));
set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0]));
set(l, "transform/rotationY", Value::Scalar(30.0));
});
// The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky,
// shadows the scene from above).
add(&mut p, cid, LayerSource::Shape, |l| {
l.switches.three_d = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let rect = build::shape_rect(ids, [240.0, 160.0], [0.0, 0.0], 0.0);
let gfill = build::shape_gradient_fill(
ids,
false,
[0.0, -80.0],
[0.0, 80.0],
Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },
);
let g = build::shape_group(ids, "Sky", vec![rect, gfill]);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.environment = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/intensity", Value::Scalar(50.0));
set(l, "lightOptions/castsShadows", Value::Bool(true));
set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| {
set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0]));
set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
set(l, "lightOptions/coneAngle", Value::Scalar(110.0));
set(l, "lightOptions/shadowDiffusion", Value::Scalar(4.0));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([200.0, 20.0, -100.0]));
set(l, "lightOptions/intensity", Value::Scalar(40.0));
set(l, "lightOptions/falloff", Value::Enum(1));
});
(p, cid)
}
pub(crate) fn scene(p: &Project, cid: ItemId) -> Scene {
let r = Renderer::new(p, &NoFootage, RenderOpts::default());
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
adv::scene_of(&r, &ctx, &run, (ctx.comp.width, ctx.comp.height))
}
fn compare(s: &Scene, g: &Gpu) {
let cpu = adv::raster::render(s);
let gt = g.raster_3d(s).expect("gpu raster");
assert_eq!((gt.width, gt.height), (cpu.width, cpu.height));
let n = cpu.color.len();
let mut bad = 0;
let mut sum = 0.0f64;
for (a, b) in cpu.color.iter().zip(&gt.color) {
let d = (0..4).map(|k| (a[k] - b[k]).abs() / a[k].abs().max(1.0)).fold(0.0f32, f32::max);
sum += d as f64;
if d > 2.0 / 255.0 {
bad += 1;
}
}
let frac = bad as f64 / n as f64;
eprintln!("adv3d gpu vs cpu: {bad}/{n} pixels beyond 2/255 ({:.3} %), mean {:.5}", frac * 100.0, sum / n as f64);
assert!(frac < 0.015, "{:.3} % of pixels differ", frac * 100.0);
assert!(sum / (n as f64) < 0.002);
// Depth agrees where both see a surface.
let mut dz = 0;
for (a, b) in cpu.depth.iter().zip(&gt.depth) {
if a.is_finite() != b.is_finite() || (a.is_finite() && (a - b).abs() > 0.01 * a.abs().max(1.0)) {
dz += 1;
}
}
assert!((dz as f64) < 0.015 * n as f64, "{dz} depth mismatches");
}
#[test]
fn gpu_matches_cpu_lit_scene() {
let Some(g) = gpu() else { return };
let (p, cid) = scene_project();
let s = scene(&p, cid);
assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32);
// The Environment light casts its shadow (the sky gradient's dominant direction).
assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built");
compare(&s, &g);
}
#[test]
fn gpu_matches_cpu_unlit_and_full_frames() {
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
// Unlit: remove the lights.
p.comp_mut(cid).unwrap().layers.retain(|l| !l.is_light());
compare(&scene(&p, cid), &g);
// Whole frames through the renderer with the GPU attached.
let (p, cid) = scene_project();
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
r.accel = Some(&g);
let gimg = r.comp_frame(cid, Tick::ZERO);
let diff = cpu.data.iter().zip(&gimg.data).filter(|(a, b)| (0..4).any(|k| (a[k] - b[k]).abs() > 3.0 / 255.0)).count();
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
}
#[test]
fn gpu_matches_cpu_shadow_catcher() {
// #1691: the floor plane as a shadow catcher ("Accepts Shadows: Only").
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
let floor = p.comp_mut(cid).unwrap().layers.last_mut().unwrap();
set(floor, "materialOptions/acceptsShadows", Value::Enum(2));
let s = scene(&p, cid);
assert!(s.materials.iter().any(|m| m.shadows_only) && !s.shadows.is_empty());
compare(&s, &g);
}
#[test]
fn gpu_matches_cpu_wide_penumbrae() {
// Both lights cast with wide Shadow Diffusion. Penumbrae cover much of the frame, so a
// backend drift in the PCF (jitter hash, tap grid, receiver-plane compare) would hide
// inside the scene-wide 1.5 % silhouette budget; here every shadowed pixel off a
// silhouette must match the CPU within 2/255.
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[1], "lightOptions/shadowDiffusion", Value::Scalar(48.0));
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(true));
set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(30.0));
let s = scene(&p, cid);
let (cpu, gt) = (adv::raster::render(&s), g.raster_3d(&s).expect("gpu raster"));
for l in &mut p.comp_mut(cid).unwrap().layers[..2] {
set(l, "lightOptions/castsShadows", Value::Bool(false));
}
let unshadowed = adv::raster::render(&scene(&p, cid));
let w = cpu.width as usize;
let luma = |c: &[f32; 4]| (c[0] + c[1] + c[2]) / 3.0;
// Off a silhouette: the pixel and its four neighbours see one continuous surface.
let smooth = |i: usize| -> bool {
let (x, y) = (i % w, i / w);
let d = cpu.depth[i];
x > 0
&& y > 0
&& x + 1 < w
&& i + w < cpu.depth.len()
&& d.is_finite()
&& [i - 1, i + 1, i - w, i + w].iter().all(|&j| (cpu.depth[j] - d).abs() < 0.01 * d.abs().max(1.0))
};
let shadowed: Vec<usize> = (0..cpu.color.len()).filter(|&i| luma(&unshadowed.color[i]) - luma(&cpu.color[i]) > 0.01 && smooth(i)).collect();
let bad = shadowed.iter().filter(|&&i| (0..4).any(|k| (cpu.color[i][k] - gt.color[i][k]).abs() / cpu.color[i][k].abs().max(1.0) > 2.0 / 255.0)).count();
eprintln!("adv3d wide penumbrae: {bad}/{} shadowed pixels beyond 2/255", shadowed.len());
assert!(shadowed.len() > 2000, "the shadows cover a large area: {}", shadowed.len());
assert!(bad as f64 <= 0.002 * shadowed.len() as f64, "{bad}/{} shadowed pixels differ", shadowed.len());
}
#[test]
fn gpu_raster_perf_smoke() {
let Some(g) = gpu() else { return };
let (p, cid) = scene_project();
let s = scene(&p, cid);
let t0 = std::time::Instant::now();
let _ = adv::raster::render(&s);
let cpu = t0.elapsed();
// First use compiles the pipelines.
let _ = g.raster_3d(&s);
let t0 = std::time::Instant::now();
let _ = g.raster_3d(&s);
let gpu = t0.elapsed();
eprintln!("adv3d 480x320 (2x SSAA of 240x160), {} triangles: cpu {:?}, gpu {:?}", s.indices.len() / 3, cpu, gpu);
}
// ---------------------------------------------------------------- whole runs (M13.11)
//
// `Accelerator::render_3d` (raster, resolve, motion-blur average, iris depth of field, encode
// on the GPU) against `adv::render_prepared` on the CPU. Tolerance: ≤ 1/255 on ≥ 99 % of the
// pixels. Against the CPU rasteriser the remaining pixels are silhouette pixels the two
// rasterisers cover differently (and the blur spreads); against the CPU post-processing of
// the *same* GPU raster the agreement is ≥ 99.9 % (f32 order of operations only).
/// The lit scene seen through a depth-of-field camera (hexagonal iris, diffraction fringe,
/// highlight boost) with the sphere moving under motion blur.
pub(crate) fn dof_mb_project() -> (Project, ItemId) {
let (mut p, cid) = scene_project();
add(&mut p, cid, LayerSource::Camera, |l| {
let zoom = match l.props.prop_mut("cameraOptions/zoom").unwrap().value {
Value::Scalar(z) => z,
_ => 300.0,
};
set(l, "cameraOptions/dof", Value::Bool(true));
set(l, "cameraOptions/focusDistance", Value::Scalar(zoom - 60.0));
set(l, "cameraOptions/aperture", Value::Scalar(160.0));
set(l, "cameraOptions/irisShape", Value::Enum(4));
set(l, "cameraOptions/irisRotation", Value::Scalar(15.0));
set(l, "cameraOptions/irisDiffractionFringe", Value::Scalar(50.0));
set(l, "cameraOptions/highlightGain", Value::Scalar(30.0));
set(l, "cameraOptions/highlightThreshold", Value::Scalar(150.0));
});
let c = p.comp_mut(cid).unwrap();
c.enable_motion_blur = true;
c.motion_blur_samples = 6;
// The sphere moves 40 px per frame.
let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap();
sphere.switches.motion_blur = true;
sphere.props.prop_mut("transform/position").unwrap().keys = vec![
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])),
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([80.0 + 30.0 * 40.0, 95.0, 0.0])),
];
(p, cid)
}
/// Share of pixels (0–1) differing by more than 1/255 on any channel, and the largest
/// difference.
pub(crate) fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
let mut off = 0;
let mut max = 0.0f32;
for (p, q) in a.iter().zip(b) {
// NaN counts as a difference (f32::max would drop it).
let d = (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0f32, |a, b| if b.is_nan() || a.is_nan() { f32::NAN } else { a.max(b) });
max = max.max(d);
if d.is_nan() || d > 1.0 / 255.0 + 1e-6 {
off += 1;
}
}
(off as f64 / a.len().max(1) as f64, max)
}
pub(crate) fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
(ctx, run)
}
fn compare_run(p: &Project, cid: ItemId, g: &Gpu, what: &str) {
let (ctx, run) = run_of(p, cid);
let out = (ctx.comp.width, ctx.comp.height);
// CPU reference: CPU raster and post.
let cpu_r = Renderer::new(p, &NoFootage, RenderOpts::default());
let prep = adv::Prepared::new(&cpu_r, &ctx, &run, out);
let (cimg, cdepth) = adv::render_prepared(&prep).expect("cpu drew");
// The GPU: everything on the device.
let (gimg, gdepth) = g.render_3d(&prep).expect("gpu handled").expect("gpu drew");
let (share, max) = off_share(&cimg.data, &gimg.data);
eprintln!("{what}: gpu vs cpu {:.3} % of pixels beyond 1/255 (max {max:.4})", share * 100.0);
assert!(share <= 0.01, "{what}: {:.3} % of pixels differ", share * 100.0);
let dz = cdepth.iter().zip(&gdepth).filter(|(a, b)| a.is_finite() != b.is_finite() || (a.is_finite() && (*a - *b).abs() > 0.01 * a.abs().max(1.0))).count();
assert!((dz as f64) < 0.015 * cdepth.len() as f64, "{what}: {dz} depth mismatches");
// CPU post-processing of the GPU raster: the post kernels alone.
let mut gr = Renderer::new(p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
gr.accel = Some(g);
let prep = adv::Prepared::new(&gr, &ctx, &run, out);
let (himg, _) = adv::render_prepared(&prep).expect("cpu post drew");
let (share, max) = off_share(&himg.data, &gimg.data);
eprintln!("{what}: gpu post vs cpu post on the gpu raster: {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
assert!(share <= 0.001, "{what}: post kernels differ on {:.3} % of pixels", share * 100.0);
}
#[test]
fn gpu_runs_match_cpu_with_dof_and_motion_blur() {
let Some(g) = gpu() else { return };
let (p, cid) = dof_mb_project();
let (ctx, run) = run_of(&p, cid);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
assert_eq!(prep.samples, 6);
let dof = prep.dof.expect("depth of field");
assert_eq!(dof.iris.sides, 6);
compare_run(&p, cid, &g, "dof + motion blur");
// The depth of field really blurs (against the same frame in focus).
let (blurred, _) = g.render_3d(&prep).unwrap().unwrap();
let mut sharp_prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
sharp_prep.dof = None;
let (sharp, _) = g.render_3d(&sharp_prep).unwrap().unwrap();
let (changed, _) = off_share(&blurred.data, &sharp.data);
assert!(changed > 0.2, "out-of-focus pixels: {:.1} %", changed * 100.0);
// Fast Rectangle iris, no highlights; and without depth of field.
let (mut p, cid) = dof_mb_project();
for l in &mut p.comp_mut(cid).unwrap().layers {
if let Some(pr) = l.props.prop_mut("cameraOptions/irisShape") {
pr.value = Value::Enum(0);
}
if let Some(pr) = l.props.prop_mut("cameraOptions/highlightGain") {
pr.value = Value::Scalar(0.0);
}
}
compare_run(&p, cid, &g, "fast rectangle");
for l in &mut p.comp_mut(cid).unwrap().layers {
if let Some(pr) = l.props.prop_mut("cameraOptions/dof") {
pr.value = Value::Bool(false);
}
}
compare_run(&p, cid, &g, "motion blur only");
}
#[test]
fn gpu_runs_match_cpu_for_extrusions_and_collapsed_precomps() {
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
// Extruded text with a bevelled stroke.
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc {
text: "Ab".into(),
size: 50.0,
fill: [0.9, 0.8, 0.2, 1.0],
apply_fill: true,
stroke: [0.1, 0.2, 0.9, 1.0],
apply_stroke: true,
stroke_width: 4.0,
..Default::default()
};
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 20_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(15.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/position", Value::Vec3([120.0, 50.0, -30.0]));
set(l, "transform/rotationY", Value::Scalar(25.0));
});
// A collapsed precomp holding a cube.
let mut nc = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
nc.renderer = R3::Classic3D;
let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into()));
add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
for k in ["width", "height", "depth"] {
set(l, &format!("geometryOptions/{k}"), Value::Scalar(30.0));
}
set(l, "transform/position", Value::Vec3([200.0, 120.0, -20.0]));
set(l, "transform/rotationY", Value::Scalar(35.0));
set(l, "transform/rotationX", Value::Scalar(-25.0));
});
add(&mut p, cid, LayerSource::Comp { item: nid }, |l| {
l.switches.three_d = true;
l.switches.collapse = true;
});
let (ctx, run) = run_of(&p, cid);
let s = adv::scene_of(&Renderer::new(&p, &NoFootage, RenderOpts::default()), &ctx, &run, (240, 160));
assert!(s.indices.len() / 3 > 500, "meshes: {} triangles", s.indices.len() / 3);
compare_run(&p, cid, &g, "extrusions + collapsed precomp");
}
#[test]
fn gpu_compositor_draws_advanced_3d_runs_on_the_device() {
let Some(g) = gpu() else { return };
// A 2D background below the run and a 2D layer above it.
let (mut p, cid) = dof_mb_project();
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
let comp = p.comp(cid).unwrap().clone();
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
p.comp_mut(cid).unwrap().layers.push(l);
let top = p.add_item("T", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 40, height: 30, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: top }, |l| set(l, "transform/opacity", Value::Scalar(50.0)));
let (ctx, run) = run_of(&p, cid);
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_some());
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
r.accel = Some(&g);
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
let (share, max) = off_share(&cpu.data, &gimg.data);
eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0);
// A blend mode in the run takes the 2D compositing path (`SplitRun`, also on the GPU).
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
effectcraft_color::BlendMode::Screen;
let (ctx, run) = run_of(&p, cid);
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none());
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
r.accel = Some(&g);
let gimg = r.comp_frame(cid, Tick::ZERO);
let (share, _) = off_share(&cpu.data, &gimg.data);
assert!(share <= 0.01, "blend-mode run: {:.3} %", share * 100.0);
}
#[test]
fn gpu_wireframes_match_cpu_exactly() {
let Some(g) = gpu() else { return };
let mut p = Project::default();
let c = Comp::new(200, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 200, height: 120, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: sid }, |_| {});
let s2 = p.add_item("W", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 70, height: 40, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: s2 }, |l| {
l.switches.quality = effectcraft_project::Quality::Wireframe;
set(l, "transform/rotation", Value::Scalar(23.0));
set(l, "transform/scale", Value::Vec3([130.0, 90.0, 100.0]));
});
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
r.accel = Some(&g);
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
let white = cpu.data.iter().filter(|q| **q == [1.0; 4]).count();
assert!(white > 100, "outline drawn: {white}");
let (share, max) = off_share(&cpu.data, &gimg.data);
assert!(share == 0.0, "{share} differ (max {max})");
}
#[test]
fn gpu_dof_with_capped_radii() {
// Radii at the 48 px cap over most of the frame (8 blur levels).
let Some(g) = gpu() else { return };
let (mut p, cid) = dof_mb_project();
for l in &mut p.comp_mut(cid).unwrap().layers {
if let Some(pr) = l.props.prop_mut("cameraOptions/aperture") {
pr.value = Value::Scalar(1500.0);
}
l.switches.motion_blur = false;
}
compare_run(&p, cid, &g, "capped radii");
}
#[test]
fn gpu_environment_light_on_flat_extrusions_has_no_nan() {
// Bevelled text faces pointing straight up or down: atan2(0, 0) in the environment lookup
// (NaN on some GPUs) must give the CPU's 0.
let Some(g) = gpu() else { return };
let mut p = Project::default();
let mut c = Comp::new(480, 270, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc =
effectcraft_keyframe::TextDoc { text: "EffectCraft".into(), size: 45.0, fill: [0.95, 0.75, 0.2, 1.0], apply_fill: true, ..Default::default() };
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 900_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(10.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/position", Value::Vec3([240.0, 65.0, 50.0]));
set(l, "transform/rotationY", Value::Scalar(-15.0));
});
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
l.environment = true;
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
let s = scene(&p, cid);
let t = g.raster_3d(&s).unwrap();
let nan = t.color.iter().filter(|c| c.iter().any(|x| !x.is_finite())).count();
assert_eq!(nan, 0, "non-finite pixels");
compare(&s, &g);
}
/// Advanced 3D runs whose layers take the 2D compositing path (blend modes, a 2D and a 3D track
/// matte, Preserve Transparency) and environment backgrounds composite on the GPU: against the
/// CPU compositor on the same (GPU) rasters they agree to float rounding; against the CPU
/// rasteriser within the raster tolerance.
#[test]
fn gpu_compositor_draws_split_advanced_3d_runs_and_skies() {
let Some(g) = gpu() else { return };
for case in 0..10 {
let (mut p, cid) = scene_project();
if case >= 4 {
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
}
let classic = case >= 8;
let case = if classic { 3 } else { case % 4 };
if classic {
// Classic 3D: the sky on the GPU too, then the run's planes.
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
}
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
let comp = p.comp(cid).unwrap().clone();
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
p.comp_mut(cid).unwrap().layers.push(l);
// A varying environment image (so the sky's lookup shows; a CPU effect, so the frame's
// only readback is its own).
let mut next = p.next_id;
let spec = effectcraft_effects::find("ec.generate.fractal").unwrap();
let ramp = effectcraft_effects::instantiate(spec, &mut build::Ids(&mut next), spec.name, [32.0, 16.0]);
p.next_id = next;
if case == 3 {
// A 2D track matte layer above the run.
let sid = p.add_item("M", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 120, height: 90, pixel_aspect: 1.0 }));
add(&mut p, cid, LayerSource::Solid { item: sid }, |m| {
m.switches.video = false;
set(m, "transform/rotation", Value::Scalar(20.0));
});
}
let c = p.comp_mut(cid).unwrap();
let env = c.layers.iter_mut().find(|l| l.environment).unwrap();
env.props.sub_mut("effects").unwrap().children.push(ramp.into());
let ids: Vec<_> = c.layers.iter().filter(|l| l.switches.three_d && !l.is_light() && !l.environment && l.source.is_av()).map(|l| l.id).collect();
let top = c.layers[0].id;
fn by_id(c: &mut Comp, id: effectcraft_project::LayerId) -> &mut effectcraft_project::Layer {
c.layers.iter_mut().find(|l| l.id == id).unwrap()
}
match case {
0 => by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Screen,
1 => {
// The card is a 3D track matte (luma) for the sphere; the torus multiplies.
let card = ids[0];
by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Multiply;
by_id(c, ids[2]).track_matte = Some(effectcraft_project::TrackMatte { layer: card, kind: effectcraft_project::MatteKind::Luma });
by_id(c, ids[0]).switches.video = false;
}
2 => {
// Preserve Transparency and an Overlay layer.
by_id(c, ids[0]).preserve_transparency = true;
by_id(c, ids[1]).blend_mode = effectcraft_color::BlendMode::Overlay;
}
_ => {
// An environment background behind the run, and the 2D track matte.
c.layers.iter_mut().find(|l| l.environment).unwrap().environment_background = true;
by_id(c, ids[0]).track_matte = Some(effectcraft_project::TrackMatte { layer: top, kind: effectcraft_project::MatteKind::Alpha });
}
}
let (ctx, run) = run_of(&p, cid);
let r0 = Renderer::new(&p, &NoFootage, RenderOpts::default());
if case < 3 {
assert!(r0.split_adv_run(&ctx, &run, (240, 160)).is_some(), "case {case}: split");
}
let cpu = r0.comp_frame(cid, Tick::ZERO);
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
r.accel = Some(&g);
// The CPU compositor on the GPU's rasters.
let hybrid = r.comp_frame_cpu(cid, Tick::ZERO);
let before = g.context().transfer_stats().readbacks;
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
// On the device end to end: the frame's own readback only (no CPU fallback).
assert_eq!(g.context().transfer_stats().readbacks - before, 1, "case {case}: readbacks");
let (share, max) = off_share(&hybrid.data, &gimg.data);
eprintln!("split case {case}: gpu walk vs cpu compositing {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
assert!(share <= 0.001, "case {case}: {:.3} % of pixels differ (max {max})", share * 100.0);
let (share, max) = off_share(&cpu.data, &gimg.data);
eprintln!("split case {case}: gpu vs cpu {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
assert!(share <= 0.015, "case {case}: {:.3} % of pixels differ from the CPU", share * 100.0);
}
}
/// An adapter whose render targets don't qualify (`R32Float` was not renderable on GL's llvmpipe;
/// the pipelines failed validation, which release builds only log): Advanced 3D renders on the
/// CPU, the GPU compositor's frame matches the CPU's, and no render pipeline is built (a
/// validation error panics under test).
#[test]
fn adapters_that_cannot_rasterise_render_advanced_3d_on_the_cpu() {
crate::tests::hold_gpu_lock();
let Some(mut ctx) = crate::context::GpuContext::headless() else { return };
ctx.adv3d_raster = false;
let g = Gpu::from_context(ctx);
let (p, cid) = scene_project();
let s = scene(&p, cid);
assert!(g.raster_3d(&s).is_none(), "no GPU raster");
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
r.accel = Some(&g);
let gimg = r.comp_frame(cid, Tick::ZERO);
let worst = cpu.data.iter().zip(&gimg.data).flat_map(|(a, b)| (0..4).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max);
assert!(worst <= 2.0 / 255.0, "GPU compositor with CPU Advanced 3D vs CPU: worst {worst}");
}
+150
View File
@@ -0,0 +1,150 @@
//! Deferred readbacks (the browser worker's WebGPU path) vs the CPU: a frame renders in passes
//! until no readback is missing, and the result matches the CPU render. Natively the same
//! device works with readbacks deferred; a device poll delivers them, as the browser's event
//! loop does.
use std::sync::OnceLock;
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource};
use effectcraft_render::{Accelerator, Backend, LayerCache, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::Gpu;
use crate::tests::{Pattern, Scene, diff, n, set, tolerance, v3};
/// The device, held exclusively: deferred state is per device and a frame renders at a time
/// (a frame worker renders one frame at a time too).
pub(crate) fn deferred_gpu() -> Option<(&'static Gpu, std::sync::MutexGuard<'static, ()>)> {
static G: OnceLock<Result<Gpu, String>> = OnceLock::new();
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
// Also no other GPU test while it runs (see `hold_gpu_lock`).
crate::tests::hold_gpu_lock();
let g = G.get_or_init(|| {
let g = pollster::block_on(Gpu::request_deferred());
match &g {
Ok(g) => eprintln!("deferred gpu tests: adapter {}", g.ctx.name),
Err(e) => eprintln!("deferred gpu tests: no GPU ({e}), skipping"),
}
g
});
let g = crate::tests::require_gpu("deferred gpu tests", g.as_ref())?;
Some((g, LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)))
}
/// Footage with GPU effects around a CPU-only one, a solid with Glow, and a precomp whose
/// layer is blurred inside and outside (a chain whose input is another chain's result).
pub(crate) fn scene() -> Scene {
let cpu_only = "ec.generate.fractal";
assert!(!effectcraft_effects::GPU_EFFECTS.contains(&cpu_only));
let mut s = Scene::new(BitDepth::Bpc32);
let mut bg = s.footage(97, 61);
s.effect(&mut bg, "ec.blur.gaussian", &[("blurriness", n(5.0))]);
s.effect(&mut bg, cpu_only, &[]);
s.effect(&mut bg, "ec.color.levels", &[("gamma", n(1.3))]);
s.push(bg);
let mut solid = s.solid([0.9, 0.4, 0.1], 30, 20);
s.effect(&mut solid, "ec.stylize.glow", &[("threshold", n(20.0)), ("radius", n(6.0))]);
set(&mut solid, "transform/position", v3(30.0, 20.0));
s.push(solid);
// Precomp: a footage layer blurred inside; the precomp layer blurred again outside.
let inner = Comp::new(60, 40, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner_id = s.p.add_item("Inner", effectcraft_color::Label::Blue, None, ItemKind::Comp(inner.clone().into()));
{
let mut ins = Scene { p: std::mem::take(&mut s.p), cid: inner_id, comp: inner };
let mut l = ins.footage(50, 30);
ins.effect(&mut l, "ec.blur.fastbox", &[("radius", n(3.0))]);
ins.push(l);
s.p = ins.p;
}
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
s.effect(&mut pre, "ec.blur.directional", &[("length", n(6.0)), ("direction", Value::Scalar(30.0))]);
set(&mut pre, "transform/position", v3(60.0, 35.0));
s.push(pre);
s
}
/// Render in passes with deferred readbacks; returns the frame and the number of passes.
pub(crate) fn render_passes(g: &Gpu, s: &Scene, opts: RenderOpts, cache: &LayerCache, t: Tick) -> (effectcraft_raster::Image, usize) {
cache.set_gate(g.miss_gate());
let mut r = Renderer::new(&s.p, &Pattern, opts);
r.accel = Some(g);
r.cache = Some(cache);
g.frame_begin();
for pass in 1..=12 {
g.pass_begin();
let img = r.comp_frame(s.cid, t);
if !g.pass_missed() {
assert_eq!(g.deferred().unwrap().in_flight(), 0);
return (img, pass);
}
// The browser awaits `Gpu::settled`; natively a blocking poll delivers the readbacks.
g.wait();
pollster::block_on(g.settled());
}
panic!("the frame never completed");
}
#[test]
fn deferred_passes_match_the_cpu() {
let Some((g, _lock)) = deferred_gpu() else { return };
assert!(g.context().can_readback() && !g.context().can_wait());
let s = scene();
let t = Tick::from_seconds_f64(0.5);
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..Default::default() }).comp_frame_cpu(s.cid, t);
let tol = tolerance(BitDepth::Bpc32) * 2.0;
// The whole frame on the GPU (GPU compositor + chains + the frame's readback).
let cache = LayerCache::default();
let (img, passes) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, ..Default::default() }, &cache, t);
let d = diff(&cpu, &img, tol);
assert!(d.over == 0, "whole-frame GPU: {d:?}");
assert!(passes >= 3, "chains, the nested chain, then the frame readback: {passes} passes");
// Again (a new frame: results are per frame; the layer cache holds the genuine buffers of
// the cacheable layers): the same pixels.
let (img2, passes2) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, ..Default::default() }, &cache, t);
assert_eq!(img2.data, img.data);
assert!(passes2 <= passes, "{passes2} <= {passes}");
// CPU compositing with GPU chains (a region of interest keeps the frame off the GPU
// compositor): every placeholder is gone by the last pass.
let roi = Some([0.0, 0.0, 97.0, 61.0]);
let cpu_roi = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi, ..Default::default() }).comp_frame_cpu(s.cid, t);
let (img3, passes3) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, roi, ..Default::default() }, &LayerCache::default(), t);
let d = diff(&cpu_roi, &img3, tol);
assert!(d.over == 0, "CPU composite, GPU chains: {d:?}");
assert!(passes3 >= 2);
let (p, rb, inflight) = g.deferred_stats().unwrap();
assert!(p >= (passes + passes2 + passes3) as u64 && rb > 0 && inflight == 0);
}
#[test]
fn placeholders_never_reach_the_layer_cache() {
let Some((g, _lock)) = deferred_gpu() else { return };
let s = scene();
let t = Tick::from_seconds_f64(1.0);
let cache = LayerCache::default();
cache.set_gate(g.miss_gate());
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() });
r.accel = Some(g);
r.cache = Some(&cache);
g.frame_begin();
g.pass_begin();
let _ = r.comp_frame(s.cid, t);
assert!(g.pass_missed());
// Only buffers computed before the first miss were kept (the first layer's input up to its
// first chain: nothing with an effect result).
let first_pass_entries = cache.stats().entries;
g.wait();
pollster::block_on(g.settled());
let (img, _) = render_passes(g, &s, RenderOpts { backend: Backend::Gpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() }, &cache, t);
assert!(cache.stats().entries > first_pass_entries, "{first_pass_entries} → {:?}", cache.stats());
// A fresh CPU render through the same cache (served from it) agrees with the CPU.
let mut cr = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() });
cr.cache = Some(&cache);
let cached = cr.comp_frame_cpu(s.cid, t);
let fresh =
Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, roi: Some([0.0, 0.0, 97.0, 61.0]), ..Default::default() }).comp_frame_cpu(s.cid, t);
let tol = tolerance(BitDepth::Bpc32) * 2.0;
assert_eq!(diff(&fresh, &cached, tol).over, 0);
assert_eq!(diff(&fresh, &img, tol).over, 0);
}
+109
View File
@@ -0,0 +1,109 @@
//! Deferred readbacks for what job workers and frame workers render besides effect chains:
//! GPU particles and Advanced 3D read back under keys too, so they render on the device in
//! passes; and the job loops' pass driver (`effectcraft_render::passes`) settles natively.
use effectcraft_effects::EffectCtx;
use effectcraft_project::BitDepth;
use effectcraft_render::three_d::adv;
use effectcraft_render::{Accelerator, Backend, LayerCache, NoFootage, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::adv3d::{off_share, run_of, scene, scene_project};
use crate::tests::deferred::{deferred_gpu, render_passes};
use crate::tests::particles::{Host, agree, params};
use crate::tests::{Pattern, diff, n, tolerance};
/// The device's readbacks have landed (the browser's event loop; natively a poll).
fn settle(g: &crate::Gpu) {
g.wait();
pollster::block_on(g.settled());
}
#[test]
fn particles_resolve_in_passes() {
let Some((g, _lock)) = deferred_gpu() else { return };
let host = Host(g);
for (id, vals) in [
("ec.sim.ccparticleworld", vec![("birthRate", n(2.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(7.0))]),
("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(90.0))]),
] {
let p = params(id, &vals);
let ctx = |env| EffectCtx { params: &p, time: 1.5, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
let run = |c: &EffectCtx| {
if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) }
};
let cpu = run(&ctx(Default::default())).unwrap();
let env = || effectcraft_effects::EffectEnv { host: Some(&host), ..Default::default() };
g.frame_begin();
g.pass_begin();
// First pass: the simulation runs, its readback starts; no particles yet, the pass missed.
let first = run(&ctx(env())).unwrap();
assert!(first.is_empty() && g.pass_missed(), "{id}");
settle(g);
g.pass_begin();
let gpu = run(&ctx(env())).unwrap();
assert!(!g.pass_missed(), "{id}: served from the readback");
agree(&format!("deferred {id}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1);
}
}
#[test]
fn advanced_3d_resolves_in_passes() {
let Some((g, _lock)) = deferred_gpu() else { return };
let (p, cid) = scene_project();
// One scene rasterised (the CPU compositor's per-scene path).
let s = scene(&p, cid);
let cpu = adv::raster::render(&s);
g.frame_begin();
g.pass_begin();
let t = g.raster_3d(&s).expect("handled");
assert!(g.pass_missed() && t.color.iter().all(|c| c[3] == 0.0), "a placeholder first");
settle(g);
g.pass_begin();
let t = g.raster_3d(&s).expect("handled");
assert!(!g.pass_missed());
let (share, _) = off_share(&cpu.color, &t.color);
assert!(share < 0.015, "raster: {:.3} % of pixels differ", share * 100.0);
// A whole prepared run (resolve, encoding) read back under its key.
let (ctx, run) = run_of(&p, cid);
let out = (ctx.comp.width, ctx.comp.height);
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let prep = adv::Prepared::new(&r, &ctx, &run, out);
let (cimg, _) = adv::render_prepared(&prep).expect("cpu drew");
g.frame_begin();
g.pass_begin();
assert!(matches!(g.render_3d(&prep), Some(None)) && g.pass_missed(), "nothing drawn yet: a placeholder");
settle(g);
g.pass_begin();
let (gimg, depth) = g.render_3d(&prep).expect("handled").expect("drawn");
assert!(!g.pass_missed());
assert_eq!(depth.len(), (out.0 * out.1) as usize);
let (share, _) = off_share(&cimg.data, &gimg.data);
assert!(share <= 0.01, "run: {:.3} % of pixels differ", share * 100.0);
}
/// The job loops' driver: `passes::comp_frame` awaits the device between passes (natively
/// `Accelerator::settle` polls it) and gives the frame `render_passes` gives.
#[test]
fn job_frames_render_in_passes() {
let Some((g, _lock)) = deferred_gpu() else { return };
let s = crate::tests::deferred::scene();
let t = Tick::from_seconds_f64(0.5);
let cache = LayerCache::default();
cache.set_gate(g.miss_gate());
let opts = RenderOpts { backend: Backend::Gpu, ..Default::default() };
let (want, _) = render_passes(g, &s, opts, &LayerCache::default(), t);
let mut r = Renderer::new(&s.p, &Pattern, opts);
r.accel = Some(g);
r.cache = Some(&cache);
let before = g.deferred_stats().unwrap();
let img = effectcraft_render::passes::block_on(effectcraft_render::passes::comp_frame(&r, s.cid, t));
let after = g.deferred_stats().unwrap();
assert!(after.0 - before.0 >= 2 && after.1 > before.1, "rendered in passes with readbacks: {before:?} → {after:?}");
assert_eq!(img.data, want.data);
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..Default::default() }).comp_frame_cpu(s.cid, t);
assert_eq!(diff(&cpu, &img, tolerance(BitDepth::Bpc32) * 2.0).over, 0);
// `in_passes` without a deferred accelerator: one pass, at once.
let one = effectcraft_render::passes::block_on(effectcraft_render::passes::in_passes(None, |_| 7));
assert_eq!((one.value, one.passes, one.accelerated), (7, 1, false));
}
+178
View File
@@ -0,0 +1,178 @@
//! GPU colour-family effects vs the CPU effects (the oracle): direct on a buffer at full and
//! half resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use crate::tests::{c, effect_case, n};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn color_balance_vibrance_tritone_mixer() {
effect_case("ec.color.colorbalance", &[("shadowRed", n(40.0)), ("midGreen", n(-30.0)), ("hiBlue", n(60.0))]);
effect_case("ec.color.colorbalance", &[("shadowBlue", n(-50.0)), ("midRed", n(70.0)), ("hiGreen", n(25.0)), ("preserveLuminosity", on())]);
effect_case("ec.color.vibrance", &[("vibrance", n(60.0)), ("saturation", n(-20.0))]);
effect_case("ec.color.vibrance", &[("vibrance", n(-40.0)), ("saturation", n(35.0))]);
effect_case("ec.color.tritone", &[("midtones", c(0.6, 0.3, 0.5)), ("shadows", c(0.0, 0.1, 0.3)), ("blend", n(30.0))]);
effect_case("ec.color.tritone", &[("highlights", c(1.0, 0.9, 0.6))]);
effect_case("ec.color.channelmixer", &[("rr", n(80.0)), ("rg", n(30.0)), ("gb", n(-40.0)), ("bc", n(10.0)), ("br", n(50.0))]);
effect_case("ec.color.channelmixer", &[("rr", n(40.0)), ("rg", n(40.0)), ("rb", n(20.0)), ("rc", n(-5.0)), ("monochrome", on())]);
}
#[test]
fn black_white_colorama_selective_color() {
effect_case("ec.color.blackwhite", &[]);
effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]);
effect_case("ec.color.blackwhite", &[("yellows", n(300.0)), ("greens", n(-200.0)), ("magentas", n(0.0))]);
effect_case("ec.color.colorama", &[]);
effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]);
effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]);
effect_case(
"ec.color.selectivecolor",
&[
("details/reds/redsCyan", n(60.0)),
("details/blues/bluesYellow", n(-40.0)),
("details/neutrals/neutralsBlack", n(20.0)),
("details/whites/whitesMagenta", n(30.0)),
],
);
effect_case(
"ec.color.selectivecolor",
&[
("method", e(1)),
("details/greens/greensMagenta", n(50.0)),
("details/yellows/yellowsCyan", n(-30.0)),
("details/blacks/blacksYellow", n(40.0)),
("details/magentas/magentasBlack", n(25.0)),
],
);
}
#[test]
fn lumetri() {
effect_case(
"ec.color.lumetri",
&[
("basicCorrection/whiteBalance/temperature", n(30.0)),
("basicCorrection/whiteBalance/tint", n(-20.0)),
("basicCorrection/tone/exposure", n(0.5)),
("basicCorrection/tone/contrast", n(25.0)),
("basicCorrection/tone/highlights", n(-40.0)),
("basicCorrection/tone/shadows", n(30.0)),
("basicCorrection/tone/whites", n(10.0)),
("basicCorrection/tone/blacks", n(-15.0)),
("basicCorrection/saturation", n(130.0)),
],
);
effect_case(
"ec.color.lumetri",
&[
("creative/lookIntensity", n(80.0)),
("creative/adjustments/fadedFilm", n(30.0)),
("creative/adjustments/vibrance", n(40.0)),
("creative/adjustments/creativeSaturation", n(70.0)),
("creative/adjustments/shadowTint", c(0.3, 0.5, 0.7)),
("creative/adjustments/highlightTint", c(0.7, 0.55, 0.4)),
("creative/adjustments/tintBalance", n(-30.0)),
("curves/curveMasterMidtones", n(30.0)),
("curves/curveRedShadows", n(-20.0)),
("curves/curveBlueHighlights", n(40.0)),
],
);
effect_case(
"ec.color.lumetri",
&[
("colorWheels/shadowsWheel", c(0.4, 0.5, 0.6)),
("colorWheels/highlightsWheel", c(0.6, 0.5, 0.45)),
("vignette/vignetteAmount", n(-2.0)),
("vignette/vignetteMidpoint", n(30.0)),
("vignette/vignetteRoundness", n(40.0)),
("creative/adjustments/sharpen", n(50.0)),
],
);
effect_case(
"ec.color.lumetri",
&[
("vignette/vignetteAmount", n(1.5)),
("vignette/vignetteRoundness", n(-60.0)),
("vignette/vignetteFeather", n(20.0)),
("creative/adjustments/sharpen", n(-40.0)),
],
);
}
#[test]
fn linear_color_key() {
effect_case("ec.key.linearcolor", &[("keyColor", c(0.6, 0.5, 0.4)), ("tolerance", n(15.0)), ("softness", n(20.0))]);
effect_case("ec.key.linearcolor", &[("keyColor", c(0.2, 0.8, 0.3)), ("matchColors", e(1)), ("keyOperation", e(1)), ("view", e(2))]);
effect_case("ec.key.linearcolor", &[("keyColor", c(0.7, 0.3, 0.6)), ("matchColors", e(2)), ("tolerance", n(5.0)), ("softness", n(30.0))]);
}
#[test]
fn key_light() {
let screen = ("screenColour", c(0.3, 0.75, 0.35));
effect_case("ec.keying.keylight", std::slice::from_ref(&screen));
effect_case(
"ec.keying.keylight",
&[
// A low gain against a pure screen keeps the raw matte away from 0 (the screen
// removal divides by it; near 0 it amplifies the blurs' float rounding).
("screenColour", c(0.0, 1.0, 0.0)),
("screenGain", n(60.0)),
("screenBalance", n(30.0)),
("screenPreblur", n(3.0)),
("screenMatte/clipBlack", n(10.0)),
("screenMatte/clipWhite", n(80.0)),
("screenMatte/clipRollback", n(2.5)),
("screenMatte/screenSoftness", n(3.0)),
("screenMatte/replaceMethod", e(3)),
("screenMatte/replaceColour", c(0.6, 0.4, 0.3)),
("unpremultiplyResult", Value::Bool(false)),
],
);
effect_case(
"ec.keying.keylight",
&[
("screenColour", c(0.2, 0.4, 0.8)),
("lockBiasesTogether", Value::Bool(false)),
("alphaBias", c(0.5, 0.45, 0.6)),
("despillBias", c(0.55, 0.5, 0.45)),
("screenMatte/clipWhite", n(70.0)),
("screenMatte/screenDespotBlack", n(2.0)),
("screenMatte/screenDespotWhite", n(1.5)),
("screenMatte/screenShrinkGrow", n(-1.5)),
("screenMatte/replaceMethod", e(2)),
("foregroundColourCorrection/enableColourCorrection", on()),
("foregroundColourCorrection/saturation", n(120.0)),
("foregroundColourCorrection/contrast", n(15.0)),
("edgeColourCorrection/enableEdgeColourCorrection", on()),
("edgeColourCorrection/edgeSoftness", n(2.0)),
("edgeColourCorrection/edgeHardness", n(30.0)),
("edgeColourCorrection/edgeBrightness", n(-20.0)),
("sourceCrops/cropLeft", n(13.0)),
("sourceCrops/cropBottom", n(7.0)),
("insideMask/sourceAlpha", e(1)),
],
);
// Soft Colour without Screen Softness (after the blur, the screen-removed foreground of
// nearly transparent pixels, divided by a raw matte near 1e-4, amplifies float rounding).
effect_case(
"ec.keying.keylight",
&[
screen.clone(),
("screenMatte/clipBlack", n(15.0)),
("screenMatte/clipRollback", n(1.5)),
("screenMatte/replaceColour", c(0.6, 0.4, 0.3)),
("insideMask/sourceAlpha", e(0)),
],
);
for view in [1, 2, 4, 7, 9] {
effect_case("ec.keying.keylight", &[screen.clone(), ("view", e(view)), ("screenMatte/screenShrinkGrow", n(1.0)), ("screenMatte/replaceMethod", e(1))]);
}
}
+275
View File
@@ -0,0 +1,275 @@
//! GPU 3D Channel family vs the CPU effects (the oracle): direct on a buffer (full and half
//! resolution, padded offsets, aux channels at 1× and 2× the layer resolution) and composited
//! at 8 and 32 bpc from footage that carries auxiliary channels.
use std::sync::Arc;
use effectcraft_effects::{EffectEnv, EffectHost, LayerPixels};
use effectcraft_keyframe::Value;
use effectcraft_project::{BitDepth, Footage, ItemId};
use effectcraft_raster::channels3d::{BACKGROUND_DEPTH, crypto_float, crypto_hash};
use effectcraft_raster::{AuxChannels, Image};
use effectcraft_render::{Backend, FootageSource, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::{Pattern, Scene, c, check, diff, gpu, n, opts, pattern, set, tolerance};
pub(crate) struct AuxHost(pub(crate) Arc<AuxChannels>);
impl EffectHost for AuxHost {
fn layer(&self, _: u64, _: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
None
}
fn aux(&self) -> Option<Arc<AuxChannels>> {
Some(self.0.clone())
}
}
const NAMES: [&str; 5] = ["alpha", "beta", "gamma", "delta", "epsilon"];
/// Aux channels for a `w`×`h` layer at `scale` aux pixels per layer pixel: a depth ramp with a
/// background corner, object / material ID blocks, coverage, UVs, normals, background RGB, raw
/// RGB and a two-rank Cryptomatte layer with a manifest.
pub(crate) fn aux_scene(w: u32, h: u32, scale: f64) -> AuxChannels {
let (aw, ah) = ((w as f64 * scale) as u32, (h as f64 * scale) as u32);
let mut a = AuxChannels::new(aw, ah, scale);
let n = (aw * ah) as usize;
let mut ch: Vec<(String, Vec<f32>)> = [
"Z",
"ObjectID",
"MaterialID",
"Coverage",
"UV.U",
"UV.V",
"N.X",
"N.Y",
"N.Z",
"BG.R",
"BG.G",
"BG.B",
"R",
"G",
"B",
"CryptoObject00.R",
"CryptoObject00.G",
"CryptoObject00.B",
"CryptoObject00.A",
]
.iter()
.map(|s| (s.to_string(), vec![0.0; n]))
.collect();
let hashes: Vec<u32> = NAMES.iter().map(|s| crypto_hash(s)).collect();
for y in 0..ah {
for x in 0..aw {
let i = (y * aw + x) as usize;
let (u, v) = (x as f32 / aw as f32, y as f32 / ah as f32);
let bg = u > 0.8 && v < 0.25;
let obj = ((x as f64 / scale) as u32 / 11 + (y as f64 / scale) as u32 / 9 * 3) % 5;
let vals = [
if bg { BACKGROUND_DEPTH } else { 50.0 + 900.0 * u + 300.0 * (v * 7.0).sin() },
if bg { 0.0 } else { obj as f32 + 1.0 },
((obj * 7) % 3) as f32,
0.5 + 0.5 * (u * 9.0).sin(),
u,
v,
(u - 0.5) * 1.6,
(0.5 - v) * 1.4,
0.7,
0.2 + 0.6 * v,
0.4,
0.9 - 0.5 * u,
u * 1.8,
v * 0.7,
0.3 + u * v * 2.5,
crypto_float(hashes[obj as usize]),
0.7,
crypto_float(hashes[(obj as usize + 1) % 5]),
0.3,
];
for (k, val) in vals.iter().enumerate() {
ch[k].1[i] = *val;
}
}
}
a.channels = ch;
a.manifests = vec![("CryptoObject".into(), NAMES.iter().zip(&hashes).map(|(n, h)| (n.to_string(), *h)).collect())];
a
}
/// The effect alone on a buffer with aux channels (`allow` = fraction of pixels that may
/// differ by more than 1e-3).
pub(crate) fn direct(id: &str, vals: &[(&str, Value)], allow: f64) {
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 aux_scale in [1.0, 2.0] {
let host = AuxHost(Arc::new(aux_scene(70, 44, aux_scale)));
for (scale, offset) in [(1.0, [0.0, 0.0]), (0.5, [0.0, 0.0]), (1.0, [3.0, 2.0])] {
let (w, h) = ((size[0] * scale) as u32, (size[1] * scale) as u32);
let mut img = pattern(7, w + 2 * offset[0] as u32, h + 2 * offset[1] as u32);
if offset[0] > 0.0 {
// A padded buffer: transparent margins.
for (i, px) in img.data.iter_mut().enumerate() {
let (x, y) = (i as u32 % img.width, i as u32 / img.width);
if x < offset[0] as u32 || y < offset[1] as u32 || x >= w + offset[0] as u32 || y >= h + offset[1] as u32 {
*px = [0.0; 4];
}
}
}
let buf = effectcraft_effects::Buf { img, offset, scale };
let ctx = || effectcraft_effects::EffectCtx {
params: &params,
time: 0.25,
layer_size: size,
seed: 11,
adjustment: false,
env: EffectEnv { host: Some(&host), ..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 as f64 <= allow * d.total as f64,
"{id} {vals:?} aux {aux_scale} scale {scale} offset {offset:?}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
/// Footage frames from [`Pattern`] with [`aux_scene`] channels.
struct AuxSource;
impl FootageSource for AuxSource {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
Pattern.frame(item, f, t)
}
fn aux(&self, _item: ItemId, f: &Footage, _t: Tick) -> Option<Arc<AuxChannels>> {
Some(Arc::new(aux_scene(f.width, f.height, 1.0)))
}
}
fn compare(s: &Scene, o: RenderOpts) -> Option<crate::tests::Diff> {
let g = gpu()?;
let cpu = Renderer::new(&s.p, &AuxSource, RenderOpts { backend: Backend::Cpu, ..o }).comp_frame_cpu(s.cid, Tick::ZERO);
let mut r = Renderer::new(&s.p, &AuxSource, RenderOpts { backend: Backend::Gpu, ..o });
r.accel = Some(g);
let img = g.render(&r, s.cid, Tick::ZERO).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)
}
/// Direct, then on a rotated footage layer over a background at 8 and 32 bpc (full and half
/// resolution).
fn case(id: &str, vals: &[(&str, Value)]) {
direct(id, vals, 0.0);
composited(id, vals, 0.0);
}
fn composited(id: &str, vals: &[(&str, Value)], allow: f64) {
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);
let bg_id = s.push(bg);
let mut l = s.footage(70, 44);
let mut vals = vals.to_vec();
for v in vals.iter_mut() {
if v.1 == Value::Layer(Some(0)) {
v.1 = Value::Layer(Some(bg_id.0));
}
}
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
check(&format!("{id} {vals:?} {depth:?}"), compare(&s, opts()), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare(&s, RenderOpts { scale: 0.5, ..opts() }), allow);
}
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn channel_extract() {
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1200.0))]);
case("ec.3d.channelextract", &[("whitePoint", n(400.0)), ("clampOutput", Value::Bool(false)), ("invertDepthMap", on())]);
case("ec.3d.channelextract", &[("blackPoint", n(900.0)), ("whitePoint", n(100.0))]);
case("ec.3d.channelextract", &[("channel", e(1)), ("whitePoint", n(5.0))]);
case("ec.3d.channelextract", &[("channel", e(2)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(3)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(4)), ("whitePoint", n(1.0)), ("antialias", on())]);
case("ec.3d.channelextract", &[("channel", e(5)), ("whitePoint", n(1.0))]);
case("ec.3d.channelextract", &[("channel", e(6))]);
case("ec.3d.channelextract", &[("channel", e(7)), ("whitePoint", n(2.0))]);
case("ec.3d.channelextract", &[("blackPoint", n(0.0)), ("whitePoint", n(1000.0)), ("antialias", on())]);
}
#[test]
fn depth_matte_fog_and_dof() {
case("ec.3d.depthmatte", &[("depth", n(500.0))]);
case("ec.3d.depthmatte", &[("depth", n(400.0)), ("feather", n(300.0)), ("invert", on())]);
case("ec.3d.fog3d", &[]);
case("ec.3d.fog3d", &[("fogColor", c(0.3, 0.5, 0.9)), ("fogStartDepth", n(200.0)), ("fogEndDepth", n(900.0)), ("scatteringDensity", n(10.0))]);
case("ec.3d.fog3d", &[("foggyBackground", Value::Bool(false)), ("fogOpacity", n(70.0))]);
composited("ec.3d.fog3d", &[("gradientLayer", Value::Layer(Some(0))), ("layerContribution", n(60.0))], 0.0);
case("ec.3d.depthoffield", &[("focalPlane", n(400.0)), ("maximumRadius", n(8.0))]);
case("ec.3d.depthoffield", &[("focalPlane", n(200.0)), ("maximumRadius", n(5.0)), ("focalPlaneThickness", n(150.0)), ("focalBias", n(80.0))]);
}
#[test]
fn ids_and_cryptomatte() {
case("ec.3d.idmatte", &[("idSelection", n(2.0))]);
case("ec.3d.idmatte", &[("idSelection", n(3.0)), ("feather", n(3.0)), ("useCoverage", on()), ("invert", on())]);
case("ec.3d.idmatte", &[("auxChannel", e(1)), ("idSelection", n(1.0))]);
case("ec.3d.identifier", &[]);
case("ec.3d.identifier", &[("id", n(2.0))]);
case("ec.3d.identifier", &[("display", e(1)), ("id", n(4.0))]);
case("ec.3d.identifier", &[("display", e(2)), ("id", n(1.0))]);
case("ec.3d.identifier", &[("display", e(3)), ("channelType", e(1))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into()))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("*a".into())), ("display", e(0))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("gamma".into())), ("display", e(1))]);
case("ec.3d.cryptomatte", &[("selection", Value::Str("alpha".into())), ("matteOnly", on())]);
}
/// The GPU skips the ID colours for displays that do not read them
/// ([`effectcraft_effects::CryptoAux::for_display`]): every display × Matte Only must still match
/// the CPU effect, so a shader that starts reading RGB in another mode fails here.
#[test]
fn cryptomatte_every_display_matches_the_cpu() {
for display in 0..3 {
for matte_only in [false, true] {
case("ec.3d.cryptomatte", &[("selection", Value::Str("beta, delta".into())), ("display", e(display)), ("matteOnly", Value::Bool(matte_only))]);
}
}
}
#[test]
fn extractor() {
case("ec.3d.extractor", &[("red", Value::Str("Z".into())), ("green", Value::Str("UV.U".into())), ("whitePoint", n(1000.0))]);
case(
"ec.3d.extractor",
&[("red", Value::Str("G".into())), ("blue", Value::Str("Coverage".into())), ("alpha", Value::Str("UV.V".into())), ("unMult", on())],
);
case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]);
case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]);
}
+168
View File
@@ -0,0 +1,168 @@
//! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`.
use effectcraft_keyframe::Value;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, LayerSource};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn twirl_bulge_ripple_wave_warp() {
effect_case("ec.distort.twirl", &[("angle", n(120.0)), ("radius", n(40.0))]);
effect_case("ec.distort.twirl", &[("angle", n(-400.0)), ("radius", n(70.0)), ("center", Value::Vec2([25.0, 18.0]))]);
effect_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]);
effect_case("ec.distort.bulge", &[("height", n(-2.5)), ("taperRadius", n(60.0)), ("pinAllEdges", on()), ("center", Value::Vec2([30.0, 25.0]))]);
effect_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]);
effect_case("ec.distort.ripple", &[("conversion", e(1)), ("phase", n(70.0)), ("speed", n(3.0)), ("center", Value::Vec2([20.0, 30.0]))]);
effect_case("ec.distort.wavewarp", &[("height", n(6.0)), ("width", n(25.0)), ("direction", n(30.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]);
// #1181: the single-edge pinning paths, with the default vertical displacement direction.
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(25.0)), ("pinning", e(4))]);
effect_case("ec.distort.wavewarp", &[("direction", n(90.0)), ("height", n(7.0)), ("width", n(19.0)), ("pinning", e(6)), ("waveType", e(1))]);
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(4)), ("speed", n(2.0))]);
effect_case("ec.distort.wavewarp", &[("direction", n(45.0)), ("height", n(-6.0)), ("pinning", e(6)), ("speed", n(2.0))]);
}
#[test]
fn turbulent_displace_and_cc_lens() {
effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
effect_case(
"ec.distort.turbulentdisplace",
&[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())],
);
effect_case(
"ec.distort.turbulentdisplace",
&[
("displacement", e(5)),
("evolutionOptions/cycleEvolution", on()),
("evolutionOptions/cycle", n(2.0)),
("evolution", n(500.0)),
("evolutionOptions/randomSeed", n(7.0)),
("pinning", e(0)),
("amount", n(-30.0)),
],
);
effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]);
effect_case("ec.distort.cclens", &[("size", n(70.0))]);
effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);
effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]);
}
/// Effect `id` on a shape layer, whose comp-sized bounds are centred on its origin and whose
/// effects run in effect space from the bounds' top-left (#227), at full and half resolution
/// (the 97 × 61 comp puts that corner between pixels).
fn shape_case(id: &str, vals: &[(&str, Value)]) {
let mut s = Scene::new(BitDepth::Bpc32);
let mut l = build::layer(&mut s.p, &s.comp, "Shape", LayerSource::Shape, (97, 61), None);
let mut next = s.p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [90.0, 54.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [0.9, 0.3, 0.1, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
s.p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
s.effect(&mut l, id, vals);
s.push(l);
check(&format!("{id} on a shape layer"), compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{id} on a shape layer, half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
/// Edge pinning on a shape layer (#227).
#[test]
fn edge_pinning_on_a_shape_layer() {
shape_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
shape_case("ec.distort.wavewarp", &[("pinning", e(1)), ("height", n(6.0)), ("width", n(25.0))]);
// #1181: single-edge pinning over the layer rect, not just All Edges.
shape_case("ec.distort.wavewarp", &[("pinning", e(4)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
shape_case("ec.distort.wavewarp", &[("pinning", e(6)), ("direction", n(90.0)), ("height", n(6.0)), ("width", n(25.0))]);
shape_case(
"ec.distort.bulge",
&[("pinAllEdges", on()), ("height", n(2.0)), ("hradius", n(40.0)), ("vradius", n(30.0)), ("center", Value::Vec2([28.5, 20.5]))],
);
}
/// Effect points on a shape layer at their defaults (the bounds' centre) and set (#227).
#[test]
fn effect_points_on_a_shape_layer() {
shape_case("ec.distort.twirl", &[("angle", n(120.0))]);
shape_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]);
shape_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]);
shape_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);
shape_case("ec.distort.turbulentdisplace", &[("size", n(25.0)), ("amount", n(30.0)), ("offset", Value::Vec2([10.0, 40.0]))]);
}
#[test]
fn mesh_warp() {
// 2 rows × 3 columns: 12 vertices.
let mesh = "0,0 0,0 0,0 0,0 0,0 6,-4 -5,3 0,0 0,0 0,0 0,0 0,0";
effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(3.0)), ("quality", n(3.0)), ("mesh", Value::Str(mesh.into()))]);
let mesh = "-4,-3 2,0 8,5 0,-2 9,9 1,0 -3,4 2,2 4,-6";
effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(2.0)), ("quality", n(5.0)), ("mesh", Value::Str(mesh.into()))]);
}
#[test]
fn mosaic_find_edges_emboss() {
effect_case("ec.stylize.mosaic", &[]);
effect_case("ec.stylize.mosaic", &[("horizontal", n(7.0)), ("vertical", n(3.0))]);
effect_case("ec.stylize.mosaic", &[("horizontal", n(13.0)), ("vertical", n(9.0)), ("sharpColors", on())]);
effect_case("ec.stylize.findedges", &[]);
effect_case("ec.stylize.findedges", &[("invert", on()), ("blend", n(30.0))]);
effect_case("ec.stylize.emboss", &[]);
effect_case("ec.stylize.emboss", &[("direction", n(160.0)), ("relief", n(4.0)), ("contrast", n(250.0)), ("blend", n(20.0))]);
}
#[test]
fn displacement_map_self() {
effect_case("ec.distort.displacementmap", &[("maxHorizontal", n(8.0)), ("maxVertical", n(-5.0))]);
effect_case("ec.distort.displacementmap", &[("useForHorizontal", e(4)), ("useForVertical", e(3)), ("wrapPixelsAround", on())]);
effect_case(
"ec.distort.displacementmap",
&[("useForHorizontal", e(10)), ("useForVertical", e(6)), ("maxVertical", n(12.0)), ("expandOutput", Value::Bool(false))],
);
}
/// Displacement Map reading another layer (Center / Stretch / Tile), composited.
#[test]
fn displacement_map_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (behavior, wrap) in [(0, false), (1, false), (2, true), (2, false)] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut map = s.footage(50, 36);
map.switches.video = false;
let mid = s.push(map);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.distort.displacementmap",
&[
("displacementMapLayer", Value::Layer(Some(mid.0))),
("useForHorizontal", e(0)),
("useForVertical", e(4)),
("maxHorizontal", n(9.0)),
("maxVertical", n(6.0)),
("displacementMapBehavior", e(behavior)),
("wrapPixelsAround", Value::Bool(wrap)),
],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("displacement map {behavior} {wrap} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
+52
View File
@@ -0,0 +1,52 @@
//! GPU shape and bevel effects (Circle, Ellipse, Iris Wipe, Bevel Alpha, Bevel Edges, Gaussian
//! Blur (Legacy)) vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use crate::tests::{c, effect_case, n};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn circle_and_ellipse() {
effect_case("ec.generate.circle", &[("center", pt(30.0, 20.0)), ("radius", n(15.0))]);
effect_case("ec.generate.circle", &[("radius", n(18.0)), ("edge", e(2)), ("thickness", n(5.0)), ("feather/feather", n(3.0)), ("blendingMode", e(3))]);
effect_case(
"ec.generate.circle",
&[("radius", n(20.0)), ("edge", e(4)), ("thickness", n(30.0)), ("feather/featherInner", n(10.0)), ("invert", on()), ("opacity", n(60.0))],
);
effect_case("ec.generate.circle", &[("radius", n(12.0)), ("edge", e(1)), ("edgeRadius", n(25.0)), ("color", c(0.2, 0.8, 0.4))]);
effect_case("ec.generate.ellipse", &[("center", pt(35.0, 22.0)), ("width", n(50.0)), ("height", n(30.0)), ("thickness", n(6.0))]);
effect_case("ec.generate.ellipse", &[("width", n(40.0)), ("height", n(60.0)), ("softness", n(40.0)), ("compositeOnOriginal", on())]);
}
#[test]
fn iris_wipe() {
effect_case("ec.transition.iriswipe", &[("outerRadius", n(20.0)), ("center", pt(35.0, 22.0))]);
effect_case(
"ec.transition.iriswipe",
&[("outerRadius", n(25.0)), ("points", n(5.0)), ("useInnerRadius", on()), ("innerRadius", n(10.0)), ("rotation", n(20.0)), ("feather", n(4.0))],
);
}
#[test]
fn bevels_and_legacy_gaussian() {
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(4.0)), ("lightIntensity", n(0.6))]);
effect_case("ec.perspective.bevelalpha", &[("edgeThickness", n(2.0)), ("lightAngle", n(200.0)), ("lightColor", c(1.0, 0.8, 0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.1)), ("lightIntensity", n(0.5))]);
effect_case("ec.perspective.beveledges", &[("edgeThickness", n(0.3)), ("lightAngle", n(130.0))]);
// Sigma 3 px and up at half scale too: smaller ones render on the CPU (#1939).
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(14.0))]);
effect_case("ec.obsolete.gaussianlegacy", &[("blurriness", n(12.0)), ("blurDimensions", e(2))]);
}
+176
View File
@@ -0,0 +1,176 @@
//! GPU generators of part C (Lightning, Advanced Lightning, Beam, Lens Flare, Radio
//! Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC
//! Threads, Audio Spectrum, Audio Waveform, Basic Text, Path Text) vs the CPU effects (the
//! oracle): direct on a buffer with masks, another layer and audio at full and half resolution
//! and as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use effectcraft_time::Tick;
use crate::tests::fx_pixel2::{e, env_case, env_case_allow, env_case_at, off, on, pt};
use crate::tests::{c, n};
fn layer(id: u64) -> Value {
Value::Layer(Some(id))
}
fn text(s: &str) -> Value {
Value::Str(s.into())
}
#[test]
fn lightning() {
env_case("ec.obsolete.lightning", &[]);
env_case(
"ec.obsolete.lightning",
&[("branching", n(0.9)), ("rebranching", n(0.6)), ("detailLevel", n(4.0)), ("blendingMode", e(1)), ("pullForce", n(40.0))],
);
env_case("ec.obsolete.lightning", &[("blendingMode", e(2)), ("rerunAtEachFrame", on()), ("fixedEndpoint", on()), ("coreWidth", n(0.8))]);
env_case("ec.generate.advancedlightning", &[]);
env_case("ec.generate.advancedlightning", &[("lightningType", e(4)), ("forking", n(60.0)), ("decayMainCore", on()), ("expertSettings/coreDrain", n(40.0))]);
env_case(
"ec.generate.advancedlightning",
&[("alphaObstacle", n(6.0)), ("compositeOnOriginal", off()), ("expertSettings/fractalType", e(2)), ("glowSettings/glowRadius", n(10.0))],
);
env_case("ec.generate.advancedlightning", &[("lightningType", e(7)), ("glowSettings/glowRadius", n(0.0)), ("coreSettings/coreRadius", n(1.5))]);
}
#[test]
fn closed_form() {
env_case("ec.generate.beam", &[]);
env_case("ec.generate.beam", &[("length", n(70.0)), ("time", n(40.0)), ("softness", n(0.0)), ("perspective3d", off()), ("compositeOnOriginal", off())]);
env_case("ec.generate.beam", &[("startThickness", n(3.0)), ("endThickness", n(20.0)), ("insideColor", c(0.3, 0.9, 1.0))]);
env_case("ec.generate.lensflare", &[]);
env_case("ec.generate.lensflare", &[("lensType", e(1)), ("flareCenter", pt(50.0, 30.0)), ("blendWithOriginal", n(30.0))]);
env_case("ec.generate.lensflare", &[("lensType", e(2)), ("flareBrightness", n(160.0))]);
env_case("ec.generate.ccgluegun", &[]);
env_case("ec.generate.ccgluegun", &[("density", n(12.0)), ("strokeWidth", n(40.0)), ("style", e(1)), ("strength", n(60.0))]);
let t = |vals: &[(&str, Value)]| env_case_allow("ec.generate.ccthreads", vals, THREAD_TIES);
t(&[]);
t(&[("texture", n(0.0))]);
// Generic values: no ties.
env_case("ec.generate.ccthreads", &[("width", n(13.1)), ("height", n(17.3)), ("overlaps", n(2.0)), ("direction", n(23.0)), ("texture", n(0.0))]);
env_case(
"ec.generate.ccthreads",
&[("width", n(9.3)), ("height", n(11.1)), ("coverage", n(55.3)), ("shadowing", n(90.0)), ("texture", n(40.0)), ("center", pt(33.7, 21.9))],
);
}
/// CC Threads decides per pixel which thread band it lies in and which fibre (the position
/// across / along the thread times 8 / 64, truncated) shades it: at parameter values that put
/// pixel centres exactly on a band edge or fibre boundary (the defaults do), f32 and the CPU's
/// f64 can land on either side.
const THREAD_TIES: f64 = 0.05;
#[test]
fn radio_waves() {
let t = Tick::from_seconds_f64(1.3);
env_case_at("ec.generate.radiowaves", &[("waveMotion/expansion", n(30.0))], 0.0, t);
env_case_at(
"ec.generate.radiowaves",
&[
("polygon/sides", n(5.0)),
("polygon/star", on()),
("waveMotion/spin", n(30.0)),
("waveStroke/profile", e(4)),
("waveMotion/velocity", n(20.0)),
("waveMotion/expansion", n(25.0)),
],
0.0,
t,
);
env_case_at(
"ec.generate.radiowaves",
&[("waveType", e(1)), ("imageContour/sourceLayer", layer(9)), ("waveStroke/fadeOutTime", n(1.0)), ("waveMotion/expansion", n(10.0))],
0.0,
t,
);
env_case_at(
"ec.generate.radiowaves",
&[
("waveType", e(2)),
("waveMask/mask", n(1.0)),
("waveMotion/reflection", on()),
("waveMotion/velocity", n(40.0)),
("waveMotion/expansion", n(15.0)),
("waveStroke/profile", e(5)),
],
0.0,
t,
);
}
#[test]
fn strokes() {
env_case("ec.generate.stroke", &[]);
env_case("ec.generate.stroke", &[("allMasks", on()), ("spacing", n(80.0)), ("paintStyle", e(1)), ("end", n(70.0)), ("brushSize", n(6.0))]);
env_case("ec.generate.stroke", &[("path", n(3.0)), ("paintStyle", e(2)), ("start", n(20.0)), ("brushHardness", n(10.0))]);
env_case("ec.generate.scribble", &[]);
env_case("ec.generate.scribble", &[("scribble", e(1)), ("fillType", e(2)), ("edgeOptions/endCap", e(2)), ("edgeOptions/join", e(1)), ("composite", e(0))]);
env_case(
"ec.generate.scribble",
&[("mask", n(2.0)), ("fillType", e(1)), ("edgeOptions/join", e(2)), ("wiggleType", e(2)), ("end", n(60.0)), ("composite", e(2))],
);
env_case("ec.generate.scribble", &[("mask", n(3.0))]);
env_case("ec.generate.vegas", &[]);
env_case("ec.generate.vegas", &[("stroke", e(1)), ("path", n(3.0)), ("blendMode", e(2)), ("segments", n(12.0)), ("randomPhase", on())]);
env_case("ec.generate.vegas", &[("inputLayer", layer(9)), ("blendMode", e(3)), ("shorterContoursHave", e(1)), ("threshold", n(30.0))]);
env_case("ec.generate.vegas", &[("stroke", e(1)), ("path", n(1.0)), ("blendMode", e(0)), ("width", n(5.0)), ("hardness", n(0.9))]);
env_case("ec.generate.writeon", &[]);
env_case("ec.generate.writeon", &[("brushPosition", pt(20.0, 30.0)), ("brushSize", n(14.0)), ("paintStyle", e(1))]);
env_case("ec.generate.writeon", &[("paintStyle", e(2)), ("brushHardness", n(20.0)), ("brushOpacity", n(60.0))]);
}
#[test]
fn fills() {
env_case("ec.generate.paintbucket", &[]);
env_case(
"ec.generate.paintbucket",
&[("fillPoint", pt(10.0, 10.0)), ("fillSelector", e(1)), ("tolerance", n(60.0)), ("stroke", e(4)), ("strokeWidth", n(4.0))],
);
env_case("ec.generate.paintbucket", &[("fillPoint", pt(35.0, 22.0)), ("fillSelector", e(2)), ("stroke", e(1)), ("color", c(0.2, 0.9, 0.4))]);
env_case("ec.generate.paintbucket", &[("stroke", e(2)), ("spreadRadius", n(2.5)), ("tolerance", n(120.0)), ("blendingMode", e(5))]);
env_case("ec.generate.paintbucket", &[("stroke", e(3)), ("tolerance", n(90.0)), ("blendingMode", e(9))]);
env_case("ec.generate.paintbucket", &[("viewThreshold", on()), ("tolerance", n(80.0))]);
env_case("ec.generate.paintbucket", &[("tolerance", n(100.0)), ("blendingMode", e(8)), ("opacity", n(70.0))]);
env_case("ec.generate.eyedropperfill", &[]);
env_case(
"ec.generate.eyedropperfill",
&[("samplePoint", pt(12.0, 30.0)), ("sampleRadius", n(6.0)), ("averagePixelColors", e(3)), ("blendWithOriginal", n(30.0))],
);
env_case("ec.generate.eyedropperfill", &[("sampleRadius", n(9.0)), ("averagePixelColors", e(1)), ("maintainOriginalAlpha", on())]);
env_case("ec.generate.eyedropperfill", &[("sampleRadius", n(4.0)), ("averagePixelColors", e(2))]);
}
#[test]
fn audio() {
let a = || ("audioLayer", layer(9));
env_case("ec.generate.audiospectrum", &[a()]);
env_case("ec.generate.audiospectrum", &[a(), ("displayOptions", e(1)), ("sideOptions", e(2)), ("compositeOnOriginal", on())]);
env_case("ec.generate.audiospectrum", &[a(), ("displayOptions", e(2)), ("path", n(3.0)), ("softness", n(0.0))]);
env_case("ec.generate.audiowaveform", &[a()]);
env_case("ec.generate.audiowaveform", &[a(), ("displayOptions", e(0)), ("waveformOptions", e(2)), ("usePolarPath", on())]);
env_case("ec.generate.audiowaveform", &[a(), ("displayOptions", e(2)), ("path", n(1.0)), ("compositeOnOriginal", on())]);
}
#[test]
fn text_effects() {
env_case("ec.obsolete.basictext", &[]);
env_case("ec.obsolete.basictext", &[("text", text("Hi\nthere")), ("fillAndStroke/displayOptions", e(2)), ("alignment", e(0)), ("size", n(14.0))]);
env_case("ec.obsolete.basictext", &[("fillAndStroke/displayOptions", e(3)), ("fillAndStroke/strokeWidth", n(5.0)), ("compositeOnOriginal", on())]);
env_case("ec.obsolete.basictext", &[("fillAndStroke/displayOptions", e(1)), ("fillAndStroke/strokeWidth", n(1.0)), ("size", n(20.0))]);
env_case("ec.obsolete.pathtext", &[]);
env_case(
"ec.obsolete.pathtext",
&[("pathOptions/shapeType", e(1)), ("advanced/visibleCharacters", n(2.5)), ("advanced/fadeTime", n(50.0)), ("character/size", n(14.0))],
);
env_case(
"ec.obsolete.pathtext",
&[
("pathOptions/customPath", n(3.0)),
("fillAndStroke/displayOptions", e(2)),
("advanced/jitterSettings/rotationJitterMax", n(20.0)),
("compositeOnOriginal", on()),
],
);
}
+202
View File
@@ -0,0 +1,202 @@
//! GPU vs CPU for the blur, transition and generate family (`fx_generate`).
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn b(v: bool) -> Value {
Value::Bool(v)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn radial_blur() {
effect_case("ec.blur.radial", &[("amount", n(12.0))]);
effect_case("ec.blur.radial", &[("amount", n(30.0)), ("type", e(1)), ("center", pt(20.3, 14.7))]);
effect_case("ec.blur.radial", &[("amount", n(-45.0)), ("center", pt(50.0, 30.0))]);
}
#[test]
fn cc_radial_fast_blur() {
effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]);
// Center outside the layer: the buffer grows so the streaks pass its edge (#2095).
effect_case("ec.blur.ccradialfast", &[("amount", n(80.0)), ("center", pt(-30.0, 20.0))]);
}
#[test]
fn camera_lens_blur() {
effect_case("ec.blur.cameralens", &[("blurRadius", n(5.0))]);
effect_case(
"ec.blur.cameralens",
&[
("blurRadius", n(7.5)),
("irisProperties/irisShape", e(2)),
("irisProperties/irisRotation", n(20.0)),
("irisProperties/irisAspectRatio", n(1.6)),
("repeatEdge", b(true)),
],
);
effect_case(
"ec.blur.cameralens",
&[
("blurRadius", n(4.0)),
("irisProperties/irisShape", e(8)),
("highlight/specularBrightness", n(60.0)),
("highlight/specularThreshold", n(150.0)),
("highlight/highlightSaturation", n(40.0)),
("useLinear", b(true)),
],
);
// Large iris: per-row prefix sums.
effect_case("ec.blur.cameralens", &[("blurRadius", n(14.0)), ("irisProperties/irisRoundness", n(60.0))]);
effect_case("ec.blur.cameralens", &[("blurRadius", n(12.0)), ("repeatEdge", b(true)), ("useLinear", b(true))]);
}
#[test]
fn wipes() {
effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]);
effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]);
effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]);
effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]);
effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]);
effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]);
effect_case("ec.transition.radialwipe", &[("completion", n(30.0)), ("startAngle", n(25.0)), ("feather", n(10.0))]);
effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]);
effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]);
effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]);
effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]);
effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]);
effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]);
}
/// Gradient Wipe driven by another layer (fetched through the effect host, placed on the CPU).
#[test]
fn gradient_wipe_with_a_gradient_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for placement in 0..3 {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let grad = s.footage(40, 30);
let gid = s.push(grad);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.transition.gradientwipe",
&[("completion", n(45.0)), ("softness", n(20.0)), ("gradientLayer", Value::Layer(Some(gid.0))), ("gradientPlacement", e(placement))],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("gradient wipe layer {placement} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn generators() {
effect_case("ec.generate.cellpattern", &[("size", n(14.0))]);
effect_case("ec.generate.cellpattern", &[("cellPattern", e(2)), ("size", n(11.0)), ("evolution", n(130.0)), ("overflow", e(1)), ("contrast", n(150.0))]);
effect_case(
"ec.generate.cellpattern",
&[
("cellPattern", e(11)),
("size", n(9.0)),
("tilingOptions/enableTiling", b(true)),
("tilingOptions/cellsHorizontal", n(4.0)),
("tilingOptions/cellsVertical", n(3.0)),
("overflow", e(2)),
("contrast", n(250.0)),
("invert", b(true)),
("evolutionOptions/randomSeed", n(17.0)),
],
);
effect_case("ec.generate.cellpattern", &[("cellPattern", e(5)), ("size", n(16.0)), ("disperse", n(0.6)), ("offset", pt(3.3, -7.1))]);
effect_case("ec.generate.checkerboard", &[("width", n(9.0))]);
effect_case(
"ec.generate.checkerboard",
&[
("sizeFrom", e(2)),
("width", n(13.0)),
("height", n(7.0)),
("feather/featherWidth", n(4.0)),
("anchor", pt(5.3, 2.2)),
("blendingMode", e(5)),
("opacity", n(70.0)),
],
);
effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]);
effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]);
effect_case(
"ec.generate.grid",
&[
("sizeFrom", e(2)),
("width", n(15.0)),
("height", n(9.0)),
("border", n(2.5)),
("feather/featherWidth", n(3.0)),
("feather/featherHeight", n(2.0)),
("invertGrid", b(true)),
("blendingMode", e(4)),
],
);
effect_case(
"ec.generate.grid",
&[("sizeFrom", e(0)), ("corner", pt(11.0, 8.0)), ("anchor", pt(2.5, 3.5)), ("blendingMode", e(14)), ("color", c(0.2, 0.8, 0.4))],
);
effect_case("ec.generate.fourcolor", &[]);
effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]);
effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]);
}
#[test]
fn noise_and_grain() {
effect_case("ec.noise.noise", &[("amount", n(30.0))]);
effect_case("ec.noise.noise", &[("amount", n(50.0)), ("color", b(false))]);
effect_case("ec.noise.noise", &[("amount", n(40.0)), ("clip", b(false))]);
effect_case("ec.noise.addgrain", &[("tweaking/intensity", n(2.0))]);
effect_case(
"ec.noise.addgrain",
&[
("tweaking/intensity", n(3.0)),
("tweaking/size", n(2.5)),
("tweaking/softness", n(1.5)),
("tweaking/aspectRatio", n(1.4)),
("tweaking/channelSize/redSize", n(0.6)),
("color/saturation", n(0.5)),
("color/tintAmount", n(0.4)),
("color/tintColor", c(1.0, 0.6, 0.2)),
("application/blendingMode", e(4)),
],
);
effect_case(
"ec.noise.addgrain",
&[
("tweaking/intensity", n(1.5)),
("color/monochromatic", b(true)),
("application/blendingMode", e(3)),
("application/shadows", n(0.3)),
("application/highlights", n(1.8)),
("application/midpoint", n(0.35)),
("animation/randomSeed", n(42.0)),
("animation/animationSpeed", n(2.0)),
],
);
effect_case(
"ec.noise.addgrain",
&[("tweaking/intensity", n(2.0)), ("application/blendingMode", e(1)), ("tweaking/channelIntensities/blueIntensity", n(2.5))],
);
}
+254
View File
@@ -0,0 +1,254 @@
//! GPU keying, matte and channel family effects vs the CPU effects (the oracle): direct on a
//! buffer at full and half resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn off() -> Value {
Value::Bool(false)
}
/// [`effect_case`] for hard thresholds (8-bit quantisation, bitwise operators): the composited
/// renders may flip a few pixels sitting on the threshold after the layer transform's float
/// rounding, so up to 0.1 % of them may differ by any amount.
fn discontinuous_case(id: &str, vals: &[(&str, Value)]) {
crate::tests::effect_direct(id, vals, false);
crate::tests::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.001);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.001);
}
}
#[test]
fn colour_luma_range_extract_keys() {
effect_case("ec.key.colorkey", &[("keyColor", c(0.7, 0.6, 0.5)), ("colorTolerance", n(60.0))]);
effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(1.5)), ("edgeFeather", n(4.0))]);
effect_case("ec.key.colorkey", &[("keyColor", c(0.5, 0.6, 0.5)), ("colorTolerance", n(70.0)), ("edgeThin", n(-2.0))]);
effect_case("ec.key.luma", &[("threshold", n(120.0)), ("tolerance", n(40.0))]);
effect_case("ec.key.luma", &[("keyType", e(0)), ("threshold", n(140.0)), ("tolerance", n(30.0)), ("edgeFeather", n(3.0))]);
effect_case("ec.key.luma", &[("keyType", e(2)), ("threshold", n(128.0)), ("tolerance", n(25.0)), ("edgeThin", n(1.0))]);
effect_case("ec.key.luma", &[("keyType", e(3)), ("threshold", n(100.0)), ("tolerance", n(50.0))]);
effect_case("ec.key.colorrange", &[("fuzziness", n(20.0))]);
effect_case("ec.key.colorrange", &[("colorSpace", e(1)), ("minL", n(60.0)), ("maxA", n(150.0)), ("minB", n(90.0)), ("fuzziness", n(15.0))]);
effect_case("ec.key.colorrange", &[("colorSpace", e(2)), ("minL", n(80.0)), ("maxA", n(180.0)), ("minB", n(40.0)), ("fuzziness", n(30.0))]);
effect_case("ec.key.extract", &[("blackPoint", n(60.0)), ("whitePoint", n(200.0)), ("blackSoftness", n(30.0)), ("whiteSoftness", n(20.0))]);
discontinuous_case("ec.key.extract", &[("channel", e(1)), ("blackPoint", n(90.0)), ("invert", on())]);
effect_case("ec.key.extract", &[("channel", e(4)), ("whitePoint", n(200.0)), ("whiteSoftness", n(40.0))]);
}
#[test]
fn difference_colour_difference_and_screen_keys() {
effect_case("ec.key.differencematte", &[]);
effect_case("ec.key.differencematte", &[("view", e(2)), ("matchingTolerance", n(0.0)), ("blurBeforeDifference", n(3.0))]);
effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8))]);
effect_case(
"ec.key.colordifference",
&[
("keyColor", c(0.3, 0.8, 0.2)),
("colorMatchingAccuracy", e(1)),
("partialAInBlack", n(20.0)),
("partialAGamma", n(1.4)),
("partialBOutWhite", n(200.0)),
("blackLevel", n(10.0)),
("gamma", n(0.8)),
],
);
for view in [1, 2, 3, 4, 5, 6, 8] {
effect_case("ec.key.colordifference", &[("keyColor", c(0.2, 0.3, 0.8)), ("view", e(view)), ("partialBInWhite", n(180.0))]);
}
effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3))]);
effect_case(
"ec.key.screen",
&[
("screenColor", c(0.3, 0.4, 0.8)),
("screenGain", n(130.0)),
("screenBalance", n(30.0)),
("despill", n(60.0)),
("clipBlack", n(10.0)),
("clipWhite", n(85.0)),
("screenPreblur", n(2.0)),
("screenShrinkGrow", n(-1.5)),
("screenSoftness", n(3.0)),
],
);
effect_case("ec.key.screen", &[("screenColor", c(0.8, 0.4, 0.3)), ("screenShrinkGrow", n(2.0)), ("edgeColor", e(1))]);
for view in [1, 2] {
effect_case("ec.key.screen", &[("screenColor", c(0.4, 0.75, 0.3)), ("view", e(view))]);
}
}
#[test]
fn spill_unmult_and_key_cleaner() {
effect_case("ec.key.spill", &[("colorToSuppress", c(0.2, 0.9, 0.1))]);
effect_case("ec.key.spill", &[("colorToSuppress", c(0.9, 0.8, 0.1)), ("colorAccuracy", e(1)), ("suppression", n(150.0))]);
effect_case("ec.key.advancedspill", &[]);
effect_case("ec.key.advancedspill", &[("suppression", n(80.0)), ("ultraSettings/spillRange", n(20.0)), ("ultraSettings/lumaCorrection", n(50.0))]);
effect_case(
"ec.key.advancedspill",
&[
("method", e(1)),
("ultraSettings/keyColor", c(0.9, 0.2, 0.3)),
("ultraSettings/tolerance", n(40.0)),
("ultraSettings/desaturate", n(50.0)),
("ultraSettings/spillColorCorrection", n(30.0)),
],
);
effect_case("ec.key.unmult", &[]);
effect_case("ec.key.unmult", &[("backgroundColor", e(1)), ("whiteLevel", n(90.0)), ("softness", n(40.0)), ("clipHdrResults", off())]);
effect_case("ec.key.unmult", &[("blackLevel", n(30.0)), ("softness", n(0.0)), ("removeColorMatting", off())]);
effect_case("ec.key.keycleaner", &[]);
effect_case("ec.key.keycleaner", &[("additionalEdgeRadius", n(6.0)), ("reduceChatter", on()), ("alphaContrast", n(250.0)), ("strength", n(60.0))]);
}
#[test]
fn chokers() {
effect_case("ec.matte.simplechoker", &[("chokeMatte", n(1.5))]);
effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-2.5))]);
effect_case("ec.matte.simplechoker", &[("chokeMatte", n(-1.0)), ("view", e(1))]);
effect_case("ec.matte.mattechoker", &[]);
effect_case(
"ec.matte.mattechoker",
&[
("geometricSoftness1", n(2.0)),
("choke1", n(-40.0)),
("geometricSoftness2", n(3.0)),
("choke2", n(30.0)),
("grayLevelSoftness2", n(40.0)),
("iterations", n(2.0)),
],
);
}
#[test]
fn refine_mattes() {
effect_case("ec.matte.refinesoft", &[]);
effect_case("ec.matte.refinesoft", &[("radius", n(4.0)), ("smooth", n(40.0)), ("feather", n(30.0)), ("contrast", n(20.0)), ("shiftEdge", n(-25.0))]);
effect_case("ec.matte.refinesoft", &[("calculateEdgeDetails", off()), ("smooth", n(50.0)), ("shiftEdge", n(30.0))]);
effect_case("ec.matte.refinesoft", &[("radius", n(3.0)), ("viewEdgeRegion", on())]);
effect_case("ec.matte.refinesoft", &[("decontamination/viewDecontaminationMap", on()), ("decontamination/extendWhereSmoothed", off())]);
effect_case("ec.matte.refinesoft", &[("decontamination/increaseDecontaminationRadius", n(4.0)), ("decontamination/decontaminationAmount", n(60.0))]);
effect_case("ec.matte.refinehard", &[]);
effect_case("ec.matte.refinehard", &[("choke", n(20.0)), ("feather", n(20.0)), ("decontaminate", off())]);
}
#[test]
fn channel_effects() {
effect_case("ec.channel.setchannels", &[("setRedTo", e(2)), ("setGreenTo", e(4)), ("setBlueTo", e(5)), ("setAlphaTo", e(6))]);
effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(3)), ("takeGreenFrom", e(7)), ("takeAlphaFrom", e(4))]);
// #1797: the constant sources at the end of the menu, where the GPU's order could drift from
// the CPU's (Full On, Full Off, Half On).
effect_case("ec.channel.shiftchannels", &[("takeRedFrom", e(8)), ("takeGreenFrom", e(9)), ("takeBlueFrom", e(10))]);
effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.3, 0.5, 0.7))]);
effect_case("ec.channel.removecolormatting", &[("backgroundColor", c(0.8, 0.2, 0.1)), ("clipHdr", off())]);
for op in [0, 2, 3, 4, 8, 10, 12] {
discontinuous_case("ec.channel.arithmetic", &[("operator", e(op)), ("redValue", n(120.0)), ("greenValue", n(60.0)), ("blueValue", n(200.0))]);
}
effect_case("ec.channel.arithmetic", &[("operator", e(3)), ("redValue", n(200.0)), ("clip", off())]);
for mode in [0, 2, 4, 9, 11] {
effect_case("ec.channel.solidcomposite", &[("blendingMode", e(mode)), ("color", c(0.8, 0.3, 0.2)), ("opacity", n(70.0)), ("sourceOpacity", n(80.0))]);
}
effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(4))]);
effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(3)), ("invertMatte", on())]);
effect_case("ec.channel.setmatte", &[("takeMatteFrom", e(1)), ("compositeMatteWithOriginal", off()), ("premultiplyMatteLayer", off())]);
for (from, to) in [(0, 0), (1, 0), (2, 0), (3, 0), (8, 4), (9, 5), (10, 6), (11, 3), (12, 8), (13, 7), (7, 1)] {
effect_case("ec.channel.combiner", &[("from", e(from)), ("to", e(to))]);
}
effect_case("ec.channel.combiner", &[("from", e(4)), ("to", e(2)), ("invert", on()), ("solidAlpha", on())]);
effect_case("ec.channel.blend", &[("mode", e(1)), ("blendWithOriginal", n(30.0))]);
effect_case("ec.channel.calculations", &[("inputChannel", e(1))]);
effect_case("ec.channel.calculations", &[("inputChannel", e(0)), ("invertInput", on())]);
effect_case("ec.channel.compoundarithmetic", &[("operator", e(3)), ("operateOnChannels", e(1))]);
}
/// The channel and keying effects that read another layer, composited.
#[test]
fn layer_inputs() {
let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![
("ec.key.differencematte", vec![("matchingTolerance", n(10.0)), ("matchingSoftness", n(20.0))]),
("ec.key.differencematte", vec![("ifLayerSizesDiffer", e(1)), ("blurBeforeDifference", n(2.0)), ("view", e(2))]),
("ec.channel.setmatte", vec![("takeMatteFrom", e(4)), ("stretchMatteToFit", off())]),
("ec.channel.setmatte", vec![("takeMatteFrom", e(3))]),
("ec.channel.setchannels", vec![("setGreenTo", e(0)), ("setAlphaTo", e(4))]),
("ec.channel.combiner", vec![("useSecondLayer", on()), ("from", e(11)), ("to", e(7))]),
("ec.channel.blend", vec![("mode", e(0)), ("blendWithOriginal", n(40.0))]),
("ec.channel.blend", vec![("mode", e(2))]),
("ec.channel.blend", vec![("mode", e(3)), ("ifLayerSizesDiffer", e(1))]),
("ec.channel.blend", vec![("mode", e(4))]),
("ec.channel.calculations", vec![("secondLayerChannel", e(2)), ("blendingMode", e(2)), ("secondLayerOpacity", n(70.0))]),
("ec.channel.calculations", vec![("inputChannel", e(4)), ("blendingMode", e(10)), ("preserveTransparency", on()), ("invertSecondLayer", on())]),
("ec.channel.compoundarithmetic", vec![("operator", e(1)), ("overflowBehavior", e(2))]),
("ec.channel.compoundarithmetic", vec![("operator", e(4)), ("operateOnChannels", e(1)), ("blendWithOriginal", n(25.0))]),
("ec.channel.compoundarithmetic", vec![("operator", e(13)), ("operateOnChannels", e(2))]),
];
let layer_param = |id: &str| match id {
"ec.key.differencematte" => vec!["differenceLayer"],
"ec.channel.setmatte" => vec!["takeMatteFromLayer"],
"ec.channel.setchannels" => vec!["sourceLayer2", "sourceLayer4"],
"ec.channel.combiner" => vec!["sourceLayer"],
"ec.channel.blend" => vec!["blendWithLayer"],
"ec.channel.calculations" => vec!["secondLayer"],
_ => vec!["secondSource"],
};
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (id, vals) in &cases {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut other = s.footage(50, 36);
other.switches.video = false;
let oid = s.push(other);
let mut l = s.footage(70, 44);
let mut vals = vals.clone();
for p in layer_param(id) {
vals.push((p, Value::Layer(Some(oid.0))));
}
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("{id} {vals:?} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn arithmetic_preserves_positive_subepsilon_alpha() {
let Some(g) = crate::tests::gpu() else { return };
let spec = effectcraft_effects::find("ec.channel.arithmetic").unwrap();
let size = [1.0, 1.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
params.values.insert("operator".into(), Value::Enum(3));
for key in ["redValue", "greenValue", "blueValue"] {
params.values.insert(key.into(), n(0.0));
}
params.values.insert("clip".into(), Value::Bool(false));
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.0, layer_size: size, seed: 0, adjustment: false, env: Default::default() };
let img = effectcraft_raster::Image::filled(1, 1, [1.0, 0.0, 0.0, 5e-7]);
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
let cpu = (spec.render)(&ctx(), buf.clone());
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap();
assert_eq!(cpu.img.get(0, 0), [1.0, 0.0, 0.0, 5e-7]);
assert_eq!(out.img.get(0, 0), cpu.img.get(0, 0));
}
+56
View File
@@ -0,0 +1,56 @@
//! GPU CC light effects (CC Light Rays, CC Light Burst 2.5, CC Light Sweep, CC Light Wipe) vs
//! the CPU effects (the oracle): direct on a buffer at full and half resolution, as
//! adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use crate::tests::{c, effect_case, n};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn light_rays() {
effect_case("ec.generate.cclightrays", &[("center", pt(30.0, 20.0))]);
effect_case(
"ec.generate.cclightrays",
&[("center", pt(40.0, 18.0)), ("intensity", n(250.0)), ("radius", n(15.0)), ("shape", e(1)), ("transferMode", e(1))],
);
effect_case(
"ec.generate.cclightrays",
&[("intensity", n(80.0)), ("colorFromSource", Value::Bool(false)), ("color", c(1.0, 0.6, 0.2)), ("transferMode", e(2)), ("warpSoftness", n(120.0))],
);
effect_case("ec.generate.cclightrays", &[("intensity", n(150.0)), ("transferMode", e(3)), ("radius", n(25.0))]);
}
#[test]
fn light_burst() {
effect_case("ec.generate.cclightburst", &[("center", pt(30.0, 20.0)), ("burst", e(1))]);
effect_case("ec.generate.cclightburst", &[("rayLength", n(80.0)), ("burst", e(2)), ("intensity", n(150.0))]);
effect_case("ec.generate.cclightburst", &[("rayLength", n(30.0)), ("setColor", on()), ("color", c(0.3, 0.7, 1.0)), ("burst", e(1))]);
effect_case("ec.generate.cclightburst", &[("rayLength", n(40.0))]);
}
#[test]
fn light_sweep() {
effect_case("ec.generate.cclightsweep", &[("center", pt(30.0, 20.0))]);
effect_case("ec.generate.cclightsweep", &[("shape", e(0)), ("width", n(30.0)), ("edgeThickness", n(4.0)), ("lightReceptionMode", e(1))]);
effect_case("ec.generate.cclightsweep", &[("shape", e(2)), ("direction", n(60.0)), ("lightReceptionMode", e(2)), ("lightColor", c(1.0, 0.8, 0.4))]);
effect_case("ec.generate.cclightsweep", &[("edgeThickness", n(0.0)), ("sweepIntensity", n(120.0))]);
}
#[test]
fn light_wipe() {
effect_case("ec.transition.cclightwipe", &[("completion", n(40.0)), ("center", pt(30.0, 20.0))]);
effect_case("ec.transition.cclightwipe", &[("completion", n(25.0)), ("shape", e(0)), ("direction", n(30.0)), ("colorFromSource", on())]);
effect_case("ec.transition.cclightwipe", &[("completion", n(60.0)), ("shape", e(2)), ("reverse", on()), ("color", c(0.2, 0.9, 0.5))]);
}
+345
View File
@@ -0,0 +1,345 @@
//! GPU colour management vs the CPU effects (the oracle): Apply Color LUT, the OCIO effects,
//! Color Profile Converter and Lumetri's Input LUT / Look, direct on a buffer (full and half
//! resolution, as adjustment) and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, effect_case, effect_direct, n, opts, set};
/// [`effect_case`] for nearest-neighbour lattice lookups: an 8 bpc input that lands exactly on a
/// rounding boundary between two lattice points may pick the other one (the GPU's division is
/// not correctly rounded), so up to `allow` of the composited pixels may differ.
fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) {
effect_direct(id, vals, false);
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} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), allow);
check(&format!("{id} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow);
}
}
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn s(v: &str) -> Value {
Value::Str(v.to_string())
}
/// A graded lattice: `f` over an `n`³ grid (red fastest).
fn lattice(n: usize, f: impl Fn([f32; 3]) -> [f32; 3]) -> String {
let mut out = String::new();
for b in 0..n {
for g in 0..n {
for r in 0..n {
let c = f([r, g, b].map(|i| i as f32 / (n - 1) as f32));
out += &format!("{} {} {}\n", c[0], c[1], c[2]);
}
}
}
out
}
fn grade(c: [f32; 3]) -> [f32; 3] {
[c[0].powf(0.8) * 1.05, c[1] * 0.9 + 0.08 * (c[0] * 5.0).sin(), c[2] * 0.7 + c[0] * 0.2 + 0.03]
}
fn cube_3d() -> String {
format!("TITLE \"grade\"\nLUT_3D_SIZE 9\n{}", lattice(9, grade))
}
fn cube_1d_3d() -> String {
let mut s = String::from("LUT_1D_SIZE 16\nLUT_3D_SIZE 7\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1.2 1.2 1.2\n");
for i in 0..16 {
let v = (i as f32 / 15.0).powf(0.6);
s += &format!("{} {} {}\n", v, v * 0.95, v.powf(1.2));
}
s + &lattice(7, grade)
}
fn cube_1d() -> String {
let mut s = String::from("LUT_1D_SIZE 32\n");
for i in 0..32 {
let v = i as f32 / 31.0;
s += &format!("{} {} {}\n", v.powf(0.7), v.powf(1.3), 0.05 + v * 0.9);
}
s
}
fn csp() -> String {
let mut s = String::from("CSPLUTV100\n3D\n\n3\n0 0.5 1\n0 0.3 1\n2\n0 1\n0 1\n4\n0 0.2 0.6 1\n0 0.4 0.7 1\n\n5 5 5\n");
for b in 0..5 {
for g in 0..5 {
for r in 0..5 {
let c = grade([r, g, b].map(|i| i as f32 / 4.0));
s += &format!("{} {} {}\n", c[0], c[1], c[2]);
}
}
}
s
}
#[test]
fn apply_color_lut() {
effect_case("ec.utility.applylut", &[("lut", s(&cube_3d()))]);
effect_case("ec.utility.applylut", &[("lut", s(&cube_1d_3d()))]);
effect_case("ec.utility.applylut", &[("lut", s(&cube_1d()))]);
effect_case("ec.utility.applylut", &[("lut", s("/nonexistent/grade.cube"))]);
}
#[test]
fn ocio_file_transform() {
for interp in 1..4 {
effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(interp))]);
}
case_allow("ec.color.ociofile", &[("file", s(&cube_3d())), ("interpolation", e(0))], 0.01);
case_allow("ec.color.ociofile", &[("file", s(&cube_1d_3d())), ("interpolation", e(0))], 0.01);
effect_case("ec.color.ociofile", &[("file", s(&cube_1d())), ("direction", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&cube_3d())), ("direction", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&csp())), ("interpolation", e(1))]);
effect_case("ec.color.ociofile", &[("file", s(&csp())), ("direction", e(1))]);
let ccc = r#"<ColorCorrectionCollection><ColorCorrection id="a"><SOPNode><Slope>1.2 1 0.9</Slope><Offset>0.02 0 -0.01</Offset><Power>1.1 0.9 1</Power></SOPNode></ColorCorrection>
<ColorCorrection id='b'><SOPNode><Slope>0.8 0.8 0.8</Slope></SOPNode><SatNode><Saturation>1.4</Saturation></SatNode></ColorCorrection></ColorCorrectionCollection>"#;
effect_case("ec.color.ociofile", &[("file", s(ccc))]);
effect_case("ec.color.ociofile", &[("file", s(ccc)), ("cccId", s("b")), ("direction", e(1))]);
}
#[test]
fn ocio_cdl_space_display_look() {
let cdl = [("slopeRed", n(1.3)), ("offsetGreen", n(0.05)), ("powerBlue", n(1.4)), ("saturation", n(1.3))];
effect_case("ec.color.ociocdl", &cdl);
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("style", e(1)), ("offsetRed", n(-0.1))]].concat());
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1))]].concat());
effect_case("ec.color.ociocdl", &[cdl.as_slice(), &[("direction", e(1)), ("style", e(1))]].concat());
let spaces = effectcraft_effects::find("ec.color.ociocolorspace").unwrap();
let names = match &spaces.params.iter().find(|p| p.id == "source").unwrap().ui {
effectcraft_project::ParamUi::Popup { options } => options.clone(),
_ => vec![],
};
for (a, z) in [("sRGB", "ACEScg"), ("ACEScct", "Display P3"), ("Gamma 2.4 Rec.2020", "ACES2065-1"), ("CIE-XYZ-D65", "sRGB"), ("Raw", "sRGB")] {
let i = |n: &str| e(names.iter().position(|x| x == n).unwrap() as u32);
effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z))]);
effect_case("ec.color.ociocolorspace", &[("source", i(a)), ("destination", i(z)), ("direction", e(1))]);
}
for display in 0..4 {
for view in 0..2 {
effect_case("ec.color.ociodisplay", &[("display", e(display)), ("view", e(view))]);
}
}
// (Inverting PQ and the roll-off together reaches values near 1000 whose matrix mix cancels
// in the small channels: f32 agrees to ~1e-5 of the pixel's largest channel there.)
effect_case("ec.color.ociodisplay", &[("display", e(3)), ("direction", e(1))]);
effect_case("ec.color.ociodisplay", &[("display", e(2)), ("view", e(1)), ("direction", e(1))]);
effect_case("ec.color.ociodisplay", &[("display", e(1)), ("direction", e(1))]);
effect_case("ec.color.ociolook", &[("look", e(3))]);
effect_case("ec.color.ociolook", &[("look", e(1)), ("looks", s("Vivid, -Cool"))]);
effect_case("ec.color.ociolook", &[("looks", s("High Contrast")), ("direction", e(1))]);
}
#[test]
fn color_profile_converter() {
for (inp, out, intent) in [(0, 1, 1), (1, 3, 0), (4, 5, 2), (6, 2, 3), (5, 1, 0), (3, 4, 1)] {
effect_case("ec.utility.colorprofileconverter", &[("inputProfile", e(inp)), ("outputProfile", e(out)), ("intent", e(intent))]);
}
effect_case(
"ec.utility.colorprofileconverter",
&[("inputProfile", e(1)), ("outputProfile", e(3)), ("linearizeInputProfile", Value::Bool(true)), ("linearizeOutputProfile", Value::Bool(true))],
);
}
#[test]
fn lumetri_luts_and_looks() {
for look in 2..8 {
effect_case("ec.color.lumetri", &[("creative/look", e(look))]);
}
effect_case("ec.color.lumetri", &[("creative/look", e(4)), ("creative/lookIntensity", n(60.0)), ("basicCorrection/tone/exposure", n(0.3))]);
effect_case("ec.color.lumetri", &[("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))]);
effect_case(
"ec.color.lumetri",
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d_3d())), ("basicCorrection/saturation", n(120.0))],
);
effect_case(
"ec.color.lumetri",
&[("basicCorrection/inputLut", e(1)), ("basicCorrection/inputLutFile", s(&cube_1d())), ("creative/look", e(1)), ("creative/lookFile", s(&cube_3d()))],
);
}
/// A custom config: exponent, matrix + offset, group (matrix, log), log-affine (inverse),
/// range, CDL and data spaces, and a display.
const CONFIG: &str = r#"ocio_profile_version: 2
roles:
scene_linear: lin
displays:
Monitor:
- !<View> {name: Video, colorspace: gamma22}
- !<View> {name: Log, colorspace: log2}
colorspaces:
- !<ColorSpace>
name: lin
- !<ColorSpace>
name: gamma22
to_scene_reference: !<ExponentTransform> {value: [2.2, 2.2, 2.2, 1]}
- !<ColorSpace>
name: half
from_reference: !<MatrixTransform> {matrix: [0.5, 0.1, 0, 0, 0, 0.5, 0, 0, 0.05, 0, 0.5, 0, 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]}
- !<ColorSpace>
name: log2
from_reference: !<GroupTransform>
children:
- !<MatrixTransform> {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]}
- !<LogTransform> {base: 2}
- !<ColorSpace>
name: affine
to_reference: !<LogAffineTransform> {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse}
- !<ColorSpace>
name: range
from_reference: !<RangeTransform> {min_in_value: 0.1, max_in_value: 0.9, min_out_value: 0, max_out_value: 1}
- !<ColorSpace>
name: graded
to_reference: !<CDLTransform> {slope: [1.2, 1, 0.9], offset: [0.02, 0, -0.01], power: [1.1, 0.9, 1], sat: 1.2}
- !<ColorSpace>
name: raw
isdata: true
"#;
#[test]
fn ocio_custom_config() {
let cfg = [("config", e(1)), ("configFile", s(CONFIG))];
for (a, z) in [
("gamma22", "lin"),
("lin", "gamma22"),
("gamma22", "half"),
("half", "lin"),
("lin", "log2"),
("log2", "graded"),
("affine", "lin"),
("lin", "affine"),
("range", "gamma22"),
("lin", "range"),
("graded", "lin"),
("raw", "half"),
("lin", "missing"),
] {
for dir in 0..2 {
let vals = [cfg.as_slice(), &[("sourceName", s(a)), ("destinationName", s(z)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociocolorspace", &vals);
}
}
for (view, dir) in [("Video", 0), ("Log", 0), ("Log", 1)] {
let vals = [cfg.as_slice(), &[("sourceName", s("half")), ("displayName", s("Monitor")), ("viewName", s(view)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociodisplay", &vals);
}
effect_case("ec.color.ociocolorspace", &[("config", e(1)), ("configFile", s("/missing.ocio"))]);
}
/// A config with the OCIO v2 transforms of Blender's (#410): an E-white XYZ reference,
/// ColorSpaceTransform, ExponentWithLinearTransform, display-referred spaces through the
/// default view transform, AllocationTransform, BuiltinTransform (a matrix, the PQ and ACEScc
/// curves), a minimum-only RangeTransform and a look in a view.
const CONFIG_V2: &str = r#"ocio_profile_version: 2
roles:
reference: XYZ E
scene_linear: Linear Rec.709
default_view_transform: Standard
view_transforms:
- !<ViewTransform>
name: Standard
from_scene_reference: !<MatrixTransform> {matrix: [0.9531, -0.0266, 0.0239, 0, -0.0382, 1.0288, 0.0094, 0, 0.0026, -0.0030, 1.0893, 0, 0, 0, 0, 1]}
displays:
sRGB:
- !<View> {name: Standard, view_transform: Standard, display_colorspace: sRGB Display}
- !<View> {name: Log, colorspace: Log, looks: +Lift}
- !<View> {name: PQ, view_transform: Standard, display_colorspace: PQ Display}
display_colorspaces:
- !<ColorSpace>
name: XYZ D65
- !<ColorSpace>
name: sRGB Display
from_display_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ D65, dst: Linear Rec.709}
- !<ExponentWithLinearTransform> {gamma: 2.4, offset: 0.055, direction: inverse}
- !<ColorSpace>
name: PQ Display
from_display_reference: !<BuiltinTransform> {style: DISPLAY - CIE-XYZ-D65_to_REC.2100-PQ}
colorspaces:
- !<ColorSpace>
name: XYZ E
- !<ColorSpace>
name: Linear Rec.709
from_scene_reference: !<GroupTransform>
children:
- !<MatrixTransform> {matrix: [0.9531, -0.0266, 0.0239, 0, -0.0382, 1.0288, 0.0094, 0, 0.0026, -0.0030, 1.0893, 0, 0, 0, 0, 1]}
- !<MatrixTransform> {matrix: [3.2410, -1.5374, -0.4986, 0, -0.9692, 1.8760, 0.0416, 0, 0.0556, -0.2040, 1.0570, 0, 0, 0, 0, 1]}
- !<ColorSpace>
name: sRGB
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: Linear Rec.709}
- !<ExponentWithLinearTransform> {gamma: 2.4, offset: 0.055, direction: inverse}
- !<ColorSpace>
name: Log
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: Linear Rec.709}
- !<AllocationTransform> {allocation: lg2, vars: [-10, 6.5]}
- !<ColorSpace>
name: ACEScg
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: XYZ D65}
- !<BuiltinTransform> {style: "UTILITY - ACES-AP1_to_CIE-XYZ-D65_BFD", direction: inverse}
- !<ColorSpace>
name: ACEScc
to_scene_reference: !<GroupTransform>
children:
- !<BuiltinTransform> {style: ACEScc_to_ACES2065-1}
- !<MatrixTransform> {matrix: [0.9525, 0, 0.0001, 0, 0.3439, 0.7281, -0.0721, 0, 0, 0, 1.0088, 0, 0, 0, 0, 1]}
- !<ColorSpace>
name: Floor
from_scene_reference: !<GroupTransform>
children:
- !<ColorSpaceTransform> {src: XYZ E, dst: sRGB}
- !<RangeTransform> {min_in_value: 0.1, min_out_value: 0.1}
- !<ColorSpace>
name: Uniform
from_scene_reference: !<AllocationTransform> {allocation: uniform, vars: [-0.5, 2]}
looks:
- !<Look>
name: Lift
process_space: Log
transform: !<CDLTransform> {offset: [0.05, 0.02, 0], sat: 0.8}
"#;
#[test]
fn ocio_custom_config_v2() {
let cfg = [("config", e(1)), ("configFile", s(CONFIG_V2))];
for (a, z) in [
("Linear Rec.709", "sRGB"),
("Linear Rec.709", "sRGB Display"),
("sRGB", "Log"),
("ACEScg", "sRGB"),
("ACEScc", "Linear Rec.709"),
("sRGB", "Floor"),
("Linear Rec.709", "Uniform"),
] {
for dir in 0..2 {
let vals = [cfg.as_slice(), &[("sourceName", s(a)), ("destinationName", s(z)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociocolorspace", &vals);
}
}
for (view, dir) in [("Standard", 0), ("Standard", 1), ("Log", 0), ("Log", 1), ("PQ", 0)] {
let vals = [cfg.as_slice(), &[("sourceName", s("Linear Rec.709")), ("displayName", s("sRGB")), ("viewName", s(view)), ("direction", e(dir))]].concat();
effect_case("ec.color.ociodisplay", &vals);
}
}
+339
View File
@@ -0,0 +1,339 @@
//! GPU noise, blur and time family effects vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn median_kernel_selection_preserves_physical_gpu_path() {
use crate::fx_noise::MedianKernel;
for backend in [wgpu::Backend::Vulkan, wgpu::Backend::Metal, wgpu::Backend::Dx12, wgpu::Backend::Gl, wgpu::Backend::BrowserWebGpu] {
for device_type in
[wgpu::DeviceType::DiscreteGpu, wgpu::DeviceType::IntegratedGpu, wgpu::DeviceType::VirtualGpu, wgpu::DeviceType::Cpu, wgpu::DeviceType::Other]
{
let selected = MedianKernel::for_adapter(backend, device_type);
let expected =
if backend == wgpu::Backend::Vulkan && device_type == wgpu::DeviceType::Cpu { MedianKernel::Software2 } else { MedianKernel::Private64 };
assert_eq!(selected, expected, "{backend:?} {device_type:?}");
}
}
assert_eq!(MedianKernel::Private64.rows_per_workgroup(), 64);
assert_eq!(MedianKernel::Software2.rows_per_workgroup(), 2);
assert_eq!(MedianKernel::Software2.workgroup_storage(), 16_384);
}
#[test]
fn medians() {
effect_case("ec.noise.median", &[("radius", n(2.0))]);
effect_case("ec.noise.median", &[("radius", n(5.0)), ("operateOnAlpha", on())]);
effect_case("ec.noise.medianlegacy", &[("radius", n(3.0))]);
effect_case("ec.noise.medianlegacy", &[("radius", n(2.0)), ("operateOnAlphaChannel", on())]);
effect_case("ec.noise.dustscratches", &[("radius", n(3.0)), ("threshold", n(10.0))]);
effect_case("ec.noise.dustscratches", &[("radius", n(2.0)), ("threshold", n(0.0)), ("operateOnAlpha", on())]);
}
#[test]
fn large_radius_medians() {
// Past the bisection radius: the sliding-histogram kernel (windows larger than the layer).
effect_case("ec.noise.median", &[("radius", n(9.0))]);
effect_case("ec.noise.median", &[("radius", n(24.0)), ("operateOnAlpha", on())]);
effect_case("ec.noise.medianlegacy", &[("radius", n(17.0))]);
effect_case("ec.noise.medianlegacy", &[("radius", n(40.0)), ("operateOnAlphaChannel", on())]);
effect_case("ec.noise.dustscratches", &[("radius", n(20.0)), ("threshold", n(10.0))]);
effect_case("ec.noise.dustscratches", &[("radius", n(60.0)), ("threshold", n(0.0)), ("operateOnAlpha", on())]);
}
#[test]
fn edge_preserving_blurs() {
effect_case("ec.blur.bilateral", &[]);
effect_case("ec.blur.bilateral", &[("radius", n(9.0)), ("threshold", n(30.0)), ("colorize", Value::Bool(false))]);
effect_case("ec.blur.bilateral", &[("radius", n(14.0)), ("threshold", n(5.0))]);
effect_case("ec.blur.smart", &[]);
effect_case("ec.blur.smart", &[("radius", n(6.0)), ("threshold", n(15.0)), ("mode", e(1))]);
effect_case("ec.blur.smart", &[("radius", n(4.0)), ("threshold", n(40.0)), ("mode", e(2))]);
}
#[test]
fn sharpen_and_unsharp_mask() {
effect_case("ec.blur.sharpen", &[("amount", n(60.0))]);
effect_case("ec.blur.sharpen", &[("amount", n(250.0))]);
effect_case("ec.blur.unsharp", &[("amount", n(120.0)), ("radius", n(4.0))]);
effect_case("ec.blur.unsharp", &[("amount", n(80.0)), ("radius", n(2.5)), ("threshold", n(12.0))]);
}
#[test]
fn channel_and_compound_blur() {
effect_case("ec.blur.channel", &[("redBlurriness", n(8.0)), ("blueBlurriness", n(3.0))]);
effect_case("ec.blur.channel", &[("greenBlurriness", n(6.0)), ("alphaBlurriness", n(6.0)), ("repeatEdge", on())]);
effect_case("ec.blur.channel", &[("redBlurriness", n(5.0)), ("alphaBlurriness", n(10.0)), ("dimensions", e(1))]);
effect_case("ec.blur.compound", &[]);
effect_case("ec.blur.compound", &[("maximumBlur", n(12.0)), ("invertBlur", on())]);
}
/// Compound Blur reading another layer (stretched and centred), composited.
#[test]
fn compound_blur_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for stretch in [true, false] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut map = s.footage(50, 36);
map.switches.video = false;
let mid = s.push(map);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.blur.compound",
&[("blurLayer", Value::Layer(Some(mid.0))), ("maximumBlur", n(10.0)), ("stretchMapToFit", Value::Bool(stretch))],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("compound blur layer {stretch} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn minimax() {
effect_case("ec.channel.minimax", &[("radius", n(3.0))]);
effect_case("ec.channel.minimax", &[("radius", n(2.0)), ("operation", e(0)), ("channel", e(2))]);
effect_case("ec.channel.minimax", &[("radius", n(4.0)), ("operation", e(2)), ("channel", e(1)), ("direction", e(1))]);
effect_case("ec.channel.minimax", &[("radius", n(3.0)), ("operation", e(3)), ("channel", e(4)), ("dontShrinkEdges", on()), ("direction", e(2))]);
}
#[test]
fn turbulent_noise() {
effect_case("ec.noise.turbulent", &[]);
effect_case(
"ec.noise.turbulent",
&[("fractalType", e(3)), ("noiseType", e(3)), ("evolution", n(120.0)), ("evolutionOptions/turbulenceFactor", n(1.5)), ("blendingMode", e(5))],
);
effect_case(
"ec.noise.turbulent",
&[("fractalType", e(5)), ("noiseType", e(1)), ("complexity", n(3.5)), ("transform/scale", n(40.0)), ("overflow", e(1)), ("blendingMode", e(0))],
);
effect_case("ec.noise.turbulent", &[("fractalType", e(7)), ("noiseType", e(0)), ("transform/perspectiveOffset", on()), ("opacity", n(70.0))]);
}
#[test]
fn fractal_noise_other_modes() {
effect_case("ec.noise.fractal", &[("noiseType", e(3)), ("fractalType", e(4)), ("blendingMode", e(3))]);
effect_case(
"ec.noise.fractal",
&[
("fractalType", e(6)),
("evolutionOptions/cycleEvolution", on()),
("evolutionOptions/cycle", n(2.0)),
("evolution", n(500.0)),
("subSettings/subRotation", n(25.0)),
("subSettings/subOffset", pt(4.0, -3.0)),
],
);
effect_case("ec.noise.fractal", &[("fractalType", e(8)), ("noiseType", e(1)), ("blendingMode", e(12)), ("contrast", n(160.0)), ("invert", on())]);
}
#[test]
fn noise_alpha_and_hls() {
effect_case("ec.noise.noisealpha", &[("amount", n(40.0))]);
effect_case("ec.noise.noisealpha", &[("amount", n(70.0)), ("noise", e(3)), ("originalAlpha", e(2)), ("overflow", e(1)), ("noisePhase", n(100.0))]);
effect_case(
"ec.noise.noisealpha",
&[("amount", n(60.0)), ("noise", e(2)), ("originalAlpha", e(3)), ("overflow", e(2)), ("noiseOptions/cycleNoise", on()), ("randomSeed", n(9.0))],
);
effect_case("ec.noise.noisehls", &[("hue", n(30.0)), ("lightness", n(20.0)), ("saturation", n(40.0))]);
effect_case("ec.noise.noisehls", &[("noise", e(1)), ("lightness", n(50.0)), ("noisePhase", n(200.0))]);
effect_case("ec.noise.noisehls", &[("noise", e(2)), ("hue", n(60.0)), ("saturation", n(25.0)), ("grainSize", n(3.0))]);
effect_case("ec.noise.noisehlsauto", &[("noise", e(0)), ("hue", n(20.0)), ("lightness", n(30.0)), ("noiseAnimationSpeed", n(7.0))]);
}
#[test]
fn remove_grain() {
effect_case("ec.noise.removegrain", &[]);
effect_case(
"ec.noise.removegrain",
&[
("noiseReductionSettings/noiseReduction", n(2.5)),
("noiseReductionSettings/passes", n(2.0)),
("noiseReductionSettings/mode", e(1)),
("fineTuning/texture", n(0.3)),
],
);
effect_case("ec.noise.removegrain", &[("unsharpMask/amount", n(80.0)), ("unsharpMask/radius", n(2.0)), ("unsharpMask/threshold", n(4.0))]);
effect_case("ec.noise.removegrain", &[("viewingMode", e(0)), ("previewRegion/showBox", on()), ("previewRegion/boxColor", c(1.0, 0.2, 0.1))]);
}
/// Echo and Posterize Time fetch other frames through the effect host: a layer whose rotation
/// is keyframed, at a nonzero time (the direct cases have no host and pass the layer through).
#[test]
fn echo_and_posterize_time() {
effect_case("ec.time.echo", &[]);
effect_case("ec.time.posterizetime", &[]);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (id, vals) in [
("ec.time.echo", vec![("numberOfEchoes", n(3.0)), ("decay", n(0.7)), ("echoTime", n(-0.1))]),
("ec.time.echo", vec![("numberOfEchoes", n(4.0)), ("echoOperator", e(2)), ("echoTime", n(0.05))]),
("ec.time.echo", vec![("numberOfEchoes", n(2.0)), ("echoOperator", e(4)), ("startingIntensity", n(0.8))]),
("ec.time.echo", vec![("numberOfEchoes", n(5.0)), ("echoOperator", e(6))]),
("ec.time.posterizetime", vec![("frameRate", n(4.0))]),
] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
// A precomp whose content moves, so the frames differ.
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.into()));
let mut a = s.footage(30, 24);
let pos = a.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(50.0, 30.0))];
let rot = a.props.prop_mut("transform/rotation").unwrap();
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))];
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let t = Tick::from_seconds_f64(0.4);
let label = format!("{id} {vals:?} {depth:?}");
check(&label, compare_at(&s, opts(), t), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
}
}
}
#[test]
fn echo_after_star_burst_preserves_the_stars() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for operator in 0..7 {
for posterize in [false, true] {
let mut s = Scene::new(depth);
let mut l = s.solid([1.0; 3], 97, 61);
s.effect(&mut l, "ec.sim.ccstarburst", &[("gridSpacing", n(8.0)), ("size", n(40.0))]);
s.effect(&mut l, "ec.time.echo", &[("numberOfEchoes", n(2.0)), ("echoTime", n(-0.1)), ("decay", n(0.6)), ("echoOperator", e(operator))]);
if posterize {
s.effect(&mut l, "ec.time.posterizetime", &[("frameRate", n(4.0))]);
}
s.push(l);
let t = Tick::from_seconds_f64(0.6);
let label = format!("Star Burst + Echo {operator} posterize={posterize} {depth:?}");
check(&label, compare_at(&s, opts(), t), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
}
}
}
}
#[test]
fn median_levels_match_cpu_division() {
let Some(g) = crate::tests::gpu() else { return };
let mut image = effectcraft_raster::Image::new(512, 1);
for (i, pixel) in image.data.iter_mut().enumerate() {
*pixel = [i as f32 / 511.0; 4];
}
let input = g.ctx.upload_image(&image).unwrap();
for radius in [3, 5] {
// A monotone row's median is its centre, including repeated edges. Exercise both
// the bisection and sliding-histogram kernels at every quantised level.
let mut enc = crate::context::Enc::new(&g.ctx);
let started = std::time::Instant::now();
let median = crate::fx_noise::median_image(&mut enc, &input, radius, false);
let actual = enc.download(&median).unwrap_or_else(|| {
panic!(
"median download radius {radius}; elapsed={:?}, health={:?}, unavailable={:?}, transfers={:?}",
started.elapsed(),
g.ctx.check_health(),
g.ctx.unavailable_kernels(),
g.ctx.transfer_stats()
)
});
for (level, (want, got)) in image.data.iter().zip(&actual.data).enumerate() {
assert_eq!(got, want, "radius {radius}, level {level}");
}
}
}
#[test]
fn dust_strict_threshold_boundaries() {
let Some(g) = crate::tests::gpu() else { return };
let spec = effectcraft_effects::find("ec.noise.dustscratches").unwrap();
let size = [7.0, 7.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
let exact = 63.75_f32;
let half_colour = [128.0 / 511.0, 128.0 / 511.0, 128.0 / 511.0, 256.0 / 511.0];
let cases = [
// The NVIDIA failure: rounding 154 / 511 down changes the keep/replace decision.
([126.0 / 511.0, 60.0 / 511.0, 62.0 / 511.0, 154.0 / 511.0], [73.0 / 255.0, 32.0 / 255.0, 32.0 / 255.0, 77.0 / 255.0], 10.0, true),
// Candidate red = 0.25, original red = 0.5. Equality must keep the original;
// adjacent representable thresholds exercise both sides of the strict comparison.
(half_colour, [0.5, 0.25, 0.25, 0.5], exact.next_down(), true),
(half_colour, [0.5, 0.25, 0.25, 0.5], exact, false),
(half_colour, [0.5, 0.25, 0.25, 0.5], exact.next_up(), false),
];
for radius in [3.0, 5.0] {
params.values.insert("radius".into(), n(radius));
for (median, original, threshold, replaced) in cases {
params.values.insert("threshold".into(), n(threshold as f64));
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.0, layer_size: size, seed: 0, adjustment: false, env: Default::default() };
let mut img = effectcraft_raster::Image::filled(7, 7, median);
img.set(3, 3, original);
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
let cpu = (spec.render)(&ctx(), buf.clone());
assert_eq!(cpu.img.data[24] != original, replaced, "CPU radius {radius}, threshold {threshold}");
let started = std::time::Instant::now();
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap_or_else(|| {
panic!(
"dust radius {radius}, threshold {threshold}; elapsed={:?}, health={:?}, unavailable={:?}, transfers={:?}",
started.elapsed(),
g.ctx.check_health(),
g.ctx.unavailable_kernels(),
g.ctx.transfer_stats()
)
});
assert_eq!(gpu.img.data[24], cpu.img.data[24], "radius {radius}, threshold {threshold}");
}
}
}
#[test]
fn dust_threshold_on_quantized_input() {
let Some(g) = crate::tests::gpu() else { return };
let spec = effectcraft_effects::find("ec.noise.dustscratches").unwrap();
let size = [70.0, 44.0];
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|p| (p.id.to_string(), effectcraft_effects::default_value(p, size))).collect() };
params.values.insert("radius".into(), n(3.0));
params.values.insert("threshold".into(), n(10.0));
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.25, layer_size: size, seed: 11, adjustment: false, env: Default::default() };
for seed in 1..32 {
let mut img = crate::tests::pattern(seed, 70, 44);
effectcraft_render::color::quantize(&mut img, 255.0);
let buf = effectcraft_effects::Buf { img, offset: [0.0; 2], scale: 1.0 };
let cpu = (spec.render)(&ctx(), buf.clone());
let gpu = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).unwrap();
for (i, (a, b)) in cpu.img.data.iter().zip(&gpu.img.data).enumerate() {
let d = a.iter().zip(b).map(|(x, y)| (x - y).abs()).fold(0.0_f32, f32::max);
assert!(d < 1e-6, "seed {seed} pixel {i} input {:?} CPU {a:?} GPU {b:?}", buf.img.data[i]);
}
}
}
+166
View File
@@ -0,0 +1,166 @@
//! GPU particle render passes (`fx_particles`) vs the CPU effects (the oracle): the frame's
//! plan is shared, the raster runs on each side. Direct on a buffer (full and half resolution)
//! and composited at 8 and 32 bpc (`tests_fx_sim`'s harness).
use effectcraft_keyframe::Value;
use effectcraft_project::{BitDepth, LayerSource, build};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::fx_sim::{case, composited, e, off, on};
use crate::tests::{Scene, check, compare_at, n, opts, set};
fn v2(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn particle_world_types_and_modes() {
for kind in 0..9 {
case(
"ec.sim.ccparticleworld",
&[("birthRate", n(1.5)), ("particle/particleType", e(kind)), ("particle/transferMode", e(kind % 4)), ("particle/birthSize", n(0.3))],
1.0,
0.0,
);
}
for anim in [1, 2, 5, 6, 8] {
case("ec.sim.ccparticleworld", &[("physics/animation", e(anim)), ("particle/particleType", e(1)), ("particle/sizeVariation", n(40.0))], 0.8, 0.0);
}
// Nothing born yet: the layer as is.
case("ec.sim.ccparticleworld", &[("birthRate", n(0.0))], 0.0, 0.0);
}
/// #397: CC Particle World seen through a camera layer (the plan is shared, so the GPU follows
/// the camera with the CPU).
#[test]
fn particle_world_through_the_comp_camera() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.footage(70, 44);
s.effect(&mut l, "ec.sim.ccparticleworld", &[("birthRate", n(1.5)), ("particle/particleType", e(1)), ("particle/birthSize", n(0.3))]);
s.push(l);
let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (97, 61), None);
set(&mut cam, "transform/position", Value::Vec3([75.0, 15.0, -120.0]));
s.push(cam);
let at = Tick::from_seconds_f64(1.0);
check(&format!("comp camera {depth:?}"), compare_at(&s, opts(), at), 0.0);
check(&format!("comp camera {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), 0.0);
}
}
#[test]
fn particle_systems_ii_types_and_modes() {
for kind in 0..9 {
case("ec.sim.ccparticlesystems2", &[("birthRate", n(3.0)), ("particle/particleType", e(kind)), ("particle/transferMode", e(kind % 4))], 1.0, 0.0);
}
case("ec.sim.ccparticlesystems2", &[("physics/animation", e(4)), ("producer/radiusX", n(8.0)), ("physics/direction", n(70.0))], 1.3, 0.0);
}
/// A Particle Playground grid (cannon off) plus `more`.
fn grid(more: &[(&'static str, Value)]) -> Vec<(&'static str, Value)> {
let mut g = vec![
("cannonEnabled", off()),
("gridEnabled", on()),
("grid/gridPosition", v2(35.0, 22.0)),
("grid/gridWidth", n(40.0)),
("grid/gridHeight", n(20.0)),
("grid/particlesAcross", n(5.0)),
("grid/particlesDown", n(3.0)),
("grid/gridParticleRadius", n(3.0)),
("gravity/gravityForce", n(20.0)),
];
g.extend(more.iter().cloned());
g
}
#[test]
fn particle_playground() {
case("ec.sim.particleplayground", &[], 1.0, 0.0);
case(
"ec.sim.particleplayground",
&[("cannon/cannonPosition", v2(20.0, 40.0)), ("cannon/particlesPerSecond", n(150.0)), ("cannon/velocity", n(90.0)), ("gravity/gravityForce", n(40.0))],
1.2,
0.0,
);
case("ec.sim.particleplayground", &grid(&[]), 0.7, 0.0);
case(
"ec.sim.particleplayground",
&grid(&[("options/gridText", Value::Str("Ab C".into())), ("grid/gridParticleRadius", n(12.0)), ("options/autoOrientRotation", on())]),
0.6,
0.0,
);
case("ec.sim.particleplayground", &grid(&[("repel/repelForce", n(5.0)), ("repel/repelForceRadius", n(12.0))]), 0.9, 0.0);
composited(
"ec.sim.particleplayground",
&[
("cannonEnabled", off()),
("exploderEnabled", on()),
("layerExploder/explodeLayer", Value::Layer(Some(0))),
("layerExploder/radiusOfNewParticles", n(3.0)),
],
0.5,
0.0,
);
// Layer Map: every grid particle shows the other layer, at frames set by the offset type.
for (kind, offset) in [(0, 0.0), (2, 0.5), (3, 1.0)] {
composited(
"ec.sim.particleplayground",
&grid(&[
("layerMapEnabled", on()),
("layerMap/layerMapLayer", Value::Layer(Some(0))),
("layerMap/timeOffsetType", e(kind)),
("layerMap/timeOffset", n(offset)),
("grid/particlesAcross", n(3.0)),
("grid/particlesDown", n(2.0)),
]),
0.8,
0.0,
);
}
composited(
"ec.sim.particleplayground",
&grid(&[
("mapperEnabled", on()),
("persistentPropertyMapper/useLayerAsMap", Value::Layer(Some(0))),
("persistentPropertyMapper/mapRedTo", e(8)),
("persistentPropertyMapper/redMin", n(-50.0)),
("persistentPropertyMapper/redMax", n(50.0)),
]),
0.7,
0.0,
);
}
#[test]
fn ball_action_pixel_polly_scatterize() {
case("ec.sim.ccballaction", &[], 0.0, 0.0);
case(
"ec.sim.ccballaction",
&[("scatter", n(20.0)), ("rotationAxis", e(6)), ("rotation", n(40.0)), ("twistProperty", e(3)), ("twistAngle", n(90.0))],
0.0,
0.0,
);
case("ec.sim.ccballaction", &[("gridSpacing", n(2.0)), ("ballSize", n(80.0)), ("instabilityState", n(45.0)), ("twistProperty", e(2))], 0.0, 0.0);
for object in 0..4 {
case("ec.sim.ccpixelpolly", &[("object", e(object)), ("gridSpacing", n(6.0))], 0.6, 0.0);
}
case("ec.sim.ccpixelpolly", &[("enableDepthSort", off()), ("spinning", n(90.0)), ("gravity", n(2.0))], 1.0, 0.0);
case("ec.sim.ccpixelpolly", &[("startTime", n(1.0))], 0.5, 0.0);
case("ec.sim.ccscatterize", &[("scatter", n(10.0))], 0.0, 0.0);
case("ec.sim.ccscatterize", &[("rightTwist", n(60.0)), ("leftTwist", n(-30.0)), ("transferMode", e(1)), ("scatter", n(4.0))], 0.0, 0.0);
case("ec.sim.ccscatterize", &[], 0.0, 0.0);
}
#[test]
fn curl_noise() {
case("ec.noise.curlnoise", &[], 0.0, 0.0);
case("ec.noise.curlnoise", &[("view", e(1))], 0.0, 0.0);
case(
"ec.noise.curlnoise",
&[("edgeBehavior", e(1)), ("steps", n(8.0)), ("displacementAmount", n(60.0)), ("scale", n(20.0)), ("rotation", n(30.0)), ("evolution", n(90.0))],
0.0,
0.0,
);
case("ec.noise.curlnoise", &[("displacementAmount", n(0.0))], 0.0, 0.0);
}
+252
View File
@@ -0,0 +1,252 @@
//! GPU pixel ports of part C (CC Cross / Radial / Vector Blur, Reduce Interlace Flicker, CC
//! Composite, Color Link, Cineon Converter, HDR Compander, HDR Highlight Compression, Grow
//! Bounds, CC Overbrights, CC Block Load, CC Burn Film, CC Glass, CC HexTile, CC Mr. Smoothie,
//! CC Plastic, Inner/Outer Key, CC Simple Wire Removal, Basic 3D) vs the CPU effects (the
//! oracle): direct on a buffer at full and half resolution, as adjustment, and composited at 8
//! and 32 bpc. Also the helpers for effects that read masks, other layers and audio.
use effectcraft_effects::{Buf, EffectEnv, EffectHost, LayerPixels, MaskShape};
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, MaskMode};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, allow_llvmpipe_boundary_flips, c, check, compare_at, diff, effect_case, gpu, n, opts, pattern, set};
pub(crate) fn e(v: u32) -> Value {
Value::Enum(v)
}
pub(crate) fn on() -> Value {
Value::Bool(true)
}
pub(crate) fn off() -> Value {
Value::Bool(false)
}
pub(crate) fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
/// Size of the test layer (layer pixels).
const SIZE: [f64; 2] = [70.0, 44.0];
/// The test layer's masks (layer pixels): a closed pentagon, a closed quad and an open curve.
pub(crate) fn mask_points() -> Vec<(Vec<[f64; 2]>, bool)> {
let pent = (0..5)
.map(|i| {
let a = i as f64 * std::f64::consts::TAU / 5.0 - 1.3;
[33.3 + a.cos() * 17.1, 22.4 + a.sin() * 15.2]
})
.collect();
vec![
(pent, true),
(vec![[6.3, 5.1], [62.2, 7.7], [59.4, 39.6], [9.1, 37.2]], true),
(vec![[4.2, 30.3], [18.7, 12.4], [33.1, 28.9], [47.6, 9.8], [64.2, 25.5]], false),
]
}
/// Another layer (`id` 9) and an audio track for layer parameters.
pub(crate) struct Host;
impl EffectHost for Host {
fn layer(&self, id: u64, _: bool) -> Option<LayerPixels> {
Some(LayerPixels { buf: Buf { img: pattern(id as u32 + 3, 70, 44), offset: [0.0; 2], scale: 1.0 }, size: SIZE })
}
fn audio(&self, _: u64, start: f64, frames: usize, rate: u32) -> Option<Vec<f32>> {
Some(
(0..frames)
.flat_map(|i| {
let t = start + i as f64 / rate as f64;
let l = 0.5 * (t * 440.0 * std::f64::consts::TAU).sin() + 0.2 * (t * 1300.0 * std::f64::consts::TAU).sin();
let r = 0.4 * (t * (200.0 + 300.0 * t) * std::f64::consts::TAU).sin();
[l as f32, r as f32]
})
.collect(),
)
}
}
/// The effect alone on a buffer with the test masks and [`Host`]: GPU vs CPU (32-bit float)
/// at full and half resolution (the half buffer padded and offset), direct and as adjustment.
/// `allow` = fraction of pixels allowed over 1e-3.
pub(crate) fn direct_env(id: &str, vals: &[(&str, Value)], allow: f64) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
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 {
assert!(params.values.contains_key(*k), "{id}: no {k}");
params.values.insert(k.to_string(), v.clone());
}
let masks: Vec<MaskShape> = mask_points()
.into_iter()
.enumerate()
.map(|(i, (points, closed))| MaskShape { name: format!("Mask {}", i + 1), points, closed, inverted: false })
.collect();
let host = Host;
for adjustment in [false, true] {
for scale in [1.0, 0.5] {
let img = pattern(7, (SIZE[0] * scale) as u32, (SIZE[1] * scale) as u32);
let mut buf = Buf { img, offset: [0.0, 0.0], scale };
if scale < 1.0 {
buf.pad(3);
}
let env = EffectEnv { masks: &masks, host: Some(&host), comp_time: 0.25, frame_rate: 30.0, ..Default::default() };
let ctx = || effectcraft_effects::EffectCtx { params: &params, time: 0.25, layer_size: SIZE, seed: 11, adjustment, env };
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 as f64 <= allow * d.total as f64,
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
/// [`direct_env`], then a footage layer with the test masks (mode None) and the effect,
/// composited over a background at 8 and 32 bpc, full and half resolution.
pub(crate) fn env_case(id: &str, vals: &[(&str, Value)]) {
env_case_allow(id, vals, 0.0);
}
/// [`env_case`] allowing a fraction of pixels over the tolerance (documented exceptions).
pub(crate) fn env_case_allow(id: &str, vals: &[(&str, Value)], allow: f64) {
env_case_at(id, vals, allow, Tick::ZERO);
}
/// [`env_case_allow`] with the composited frames at comp time `t`.
pub(crate) fn env_case_at(id: &str, vals: &[(&str, Value)], allow: f64, t: Tick) {
direct_env(id, vals, allow);
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);
let mut next = s.p.next_id;
for (i, (pts, closed)) in mask_points().into_iter().enumerate() {
let m = build::mask(&mut Ids(&mut next), &format!("Mask {}", i + 1), ShapePath::polygon(&pts, closed), MaskMode::None, [255, 255, 0]);
l.props.sub_mut("masks").unwrap().children.push(m.into());
}
s.p.next_id = next;
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(), t), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), allow);
}
}
#[test]
fn blurs() {
effect_case("ec.blur.cccross", &[]);
effect_case("ec.blur.cccross", &[("radiusX", n(4.0)), ("radiusY", n(0.0)), ("transferMode", e(1))]);
effect_case("ec.blur.cccross", &[("radiusX", n(3.0)), ("radiusY", n(7.0)), ("transferMode", e(2))]);
effect_case("ec.blur.cccross", &[("radiusX", n(6.0)), ("radiusY", n(2.0)), ("transferMode", e(3))]);
effect_case("ec.blur.ccradial", &[]);
effect_case("ec.blur.ccradial", &[("type", e(0)), ("amount", n(-40.0)), ("center", pt(20.0, 30.0))]);
effect_case("ec.blur.ccradial", &[("type", e(2)), ("amount", n(60.0)), ("quality", n(20.0))]);
effect_case("ec.blur.ccradial", &[("type", e(3)), ("amount", n(30.0))]);
effect_case("ec.blur.ccradial", &[("type", e(4)), ("amount", n(-50.0)), ("quality", n(80.0))]);
effect_case("ec.blur.ccvector", &[]);
effect_case("ec.blur.ccvector", &[("type", e(1)), ("amount", n(6.0)), ("angleOffset", n(30.0))]);
effect_case("ec.blur.ccvector", &[("type", e(2)), ("mapSoftness", n(0.0))]);
effect_case("ec.blur.ccvector", &[("type", e(3)), ("amount", n(5.0))]);
effect_case("ec.blur.ccvector", &[("type", e(4)), ("amount", n(12.0)), ("mapSoftness", n(40.0))]);
effect_case("ec.blur.reduceflicker", &[("softness", n(3.0))]);
effect_case("ec.blur.reduceflicker", &[("softness", n(0.5))]);
}
#[test]
fn composites_and_colour() {
for m in [0, 1, 2, 3, 5, 8, 11, 14, 15] {
effect_case("ec.channel.cccomposite", &[("compositeOriginal", e(m)), ("opacity", n(70.0))]);
}
effect_case("ec.channel.cccomposite", &[("compositeOriginal", e(4)), ("rgbOnly", on())]);
effect_case("ec.color.colorlink", &[]);
effect_case("ec.color.colorlink", &[("sampleSource", e(2)), ("clip", n(10.0)), ("blendingMode", e(3)), ("opacity", n(60.0))]);
effect_case("ec.color.colorlink", &[("sampleSource", e(1)), ("blendingMode", e(11)), ("stencilOriginalAlpha", off())]);
effect_case("ec.color.colorlink", &[("sampleSource", e(9)), ("clip", n(20.0))]);
effect_case("ec.color.colorlink", &[("sampleSource", e(6)), ("opacity", n(50.0)), ("stencilOriginalAlpha", off())]);
env_case("ec.color.colorlink", &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", e(4)), ("clip", n(5.0))]);
effect_case("ec.utility.cineon", &[]);
effect_case("ec.utility.cineon", &[("conversionType", e(0)), ("gamma", n(2.2))]);
effect_case("ec.utility.cineon", &[("conversionType", e(2)), ("highlightRolloff", n(60.0)), ("tenBitWhitePoint", n(800.0))]);
effect_case("ec.utility.hdrcompander", &[("gain", n(4.0)), ("gamma", n(2.0))]);
effect_case("ec.utility.hdrcompander", &[("mode", e(1)), ("gain", n(3.0)), ("gamma", n(1.5))]);
effect_case("ec.utility.hdrcompression", &[]);
effect_case("ec.utility.hdrcompression", &[("amount", n(40.0))]);
effect_case("ec.utility.growbounds", &[("pixels", n(9.0))]);
effect_case("ec.utility.ccoverbrights", &[]);
for ch in 1..=5 {
effect_case("ec.utility.ccoverbrights", &[("channel", e(ch)), ("highlightColor", c(0.2, 0.9, 0.4))]);
}
}
#[test]
fn stylize() {
let _flips = allow_llvmpipe_boundary_flips();
for (done, flags) in
[(30.0, [true, true, true, false]), (55.0, [false, true, false, true]), (70.0, [true, false, false, false]), (10.0, [false, false, true, true])]
{
effect_case(
"ec.stylize.ccblockload",
&[
("completion", n(done)),
("scanlines", Value::Bool(flags[0])),
("smoothing", Value::Bool(flags[1])),
("startCleared", Value::Bool(flags[2])),
("bilinear", Value::Bool(flags[3])),
],
);
}
effect_case("ec.stylize.ccburnfilm", &[("burn", n(30.0))]);
effect_case("ec.stylize.ccburnfilm", &[("burn", n(60.0)), ("center", pt(20.0, 15.0)), ("randomSeed", n(4.0))]);
effect_case("ec.stylize.ccglass", &[]);
effect_case("ec.stylize.ccglass", &[("lightType", e(1)), ("displacement", n(-60.0)), ("property", e(4)), ("softness", n(4.0))]);
effect_case("ec.stylize.ccglass", &[("height", n(0.0)), ("lightColor", c(1.0, 0.8, 0.5)), ("metal", n(60.0))]);
effect_case("ec.stylize.ccplastic", &[]);
effect_case("ec.stylize.ccplastic", &[("lightType", e(1)), ("cutMin", n(20.0)), ("cutMax", n(70.0)), ("property", e(5))]);
env_case("ec.stylize.ccplastic", &[("bumpLayer", Value::Layer(Some(9))), ("property", e(0)), ("roughness", n(0.2))]);
effect_case("ec.stylize.cchextile", &[("radius", n(9.3))]);
effect_case("ec.stylize.cchextile", &[("radius", n(7.7)), ("render", e(1)), ("rotate", n(17.0)), ("smearing", n(30.0))]);
effect_case("ec.stylize.cchextile", &[("radius", n(12.1)), ("render", e(2)), ("center", pt(31.3, 19.7))]);
effect_case("ec.stylize.ccmrsmoothie", &[]);
effect_case("ec.stylize.ccmrsmoothie", &[("property", e(6)), ("smoothness", n(5.0)), ("colorLoop", e(2)), ("phase", n(40.0))]);
env_case("ec.stylize.ccmrsmoothie", &[("flowLayer", Value::Layer(Some(9))), ("property", e(1))]);
}
#[test]
fn keys_and_3d() {
env_case("ec.key.innerouter", &[]);
env_case("ec.key.innerouter", &[("foreground", e(1))]);
direct_env("ec.key.innerouter", &[("foreground", e(1)), ("background", e(2))], 0.0);
env_case("ec.key.innerouter", &[("foreground", e(1)), ("background", e(2)), ("edgeFeather", n(3.0)), ("edgeThin", n(1.5))]);
env_case("ec.key.innerouter", &[("foreground", e(1)), ("singleMaskHighlightRadius", n(4.0)), ("invertExtraction", on()), ("blendWithOriginal", n(20.0))]);
env_case(
"ec.key.innerouter",
&[
("foreground", e(2)),
("edgeThin", n(-2.3)),
("edgeThreshold", n(30.0)),
("cleanupForeground/cleanupForeground1/path", e(3)),
("cleanupBackground/cleanupBackground1/path", e(1)),
],
);
effect_case("ec.key.ccsimplewireremoval", &[]);
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(0)), ("thickness", n(8.0)), ("slope", n(50.0))]);
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("mirrorBlend", n(60.0)), ("pointB", pt(60.0, 10.0))]);
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(1)), ("frameOffset", n(-2.0)), ("thickness", n(6.0))]);
effect_case("ec.key.ccsimplewireremoval", &[("removalStyle", e(3)), ("mirrorBlend", n(30.0)), ("slope", n(20.0))]);
effect_case("ec.obsolete.basic3d", &[("swivel", n(25.0))]);
effect_case("ec.obsolete.basic3d", &[("tilt", n(-30.0)), ("swivel", n(10.0)), ("specularHighlight", on())]);
effect_case("ec.obsolete.basic3d", &[("distanceToImage", n(20.0)), ("preview/drawPreviewWireframe", on()), ("tilt", n(15.0))]);
}
+184
View File
@@ -0,0 +1,184 @@
//! GPU simulation render passes vs the CPU effects (the oracle): the frame's plan (sprites,
//! pieces, blobs, drops, height grid) is shared, the raster / shading pass runs on each side.
//! Direct on a buffer (full and half resolution) and composited at 8 and 32 bpc, at a time
//! where the simulations have something to show.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set};
pub(crate) fn e(v: u32) -> Value {
Value::Enum(v)
}
pub(crate) fn on() -> Value {
Value::Bool(true)
}
pub(crate) fn off() -> Value {
Value::Bool(false)
}
/// The effect alone on a buffer at layer time `t` (`allow` = fraction of pixels over 1e-3).
pub(crate) fn direct(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
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: t, layer_size: size, seed: 11, adjustment: false, 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 as f64 <= allow * d.total as f64,
"{id} {vals:?} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
/// A footage layer with the effect over a background and a second footage layer (the target of
/// `Value::Layer(Some(0))` parameters), composited at 8 and 32 bpc at comp time `t`.
pub(crate) fn composited(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
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 other = s.footage(60, 50);
let other_id = s.push(other);
let mut l = s.footage(70, 44);
let vals: Vec<(&str, Value)> =
vals.iter().map(|(k, v)| (*k, if *v == Value::Layer(Some(0)) { Value::Layer(Some(other_id.0)) } else { v.clone() })).collect();
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let at = Tick::from_seconds_f64(t);
check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), at), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), allow);
}
}
pub(crate) fn case(id: &str, vals: &[(&str, Value)], t: f64, allow: f64) {
direct(id, vals, t, allow);
composited(id, vals, t, allow);
}
#[test]
fn weather_and_star_burst() {
case("ec.sim.ccrainfall", &[("drops", n(800.0)), ("size", n(3.0))], 0.5, 0.0);
case("ec.sim.ccrainfall", &[("drops", n(500.0)), ("extras/appearance", e(1)), ("transferMode", e(0)), ("wind", n(800.0))], 0.7, 0.0);
case("ec.sim.ccrainfall", &[("drops", n(300.0)), ("compositeWithOriginal", off()), ("size", n(6.0))], 0.3, 0.0);
case("ec.sim.ccsnowfall", &[("flakes", n(600.0)), ("size", n(6.0))], 0.5, 0.0);
case("ec.sim.ccsnowfall", &[("flakes", n(400.0)), ("transferMode", e(1)), ("wiggle/wiggleAmount", n(8.0))], 1.2, 0.0);
case("ec.sim.ccstarburst", &[("gridSpacing", n(6.0))], 0.4, 0.0);
case("ec.sim.ccstarburst", &[("gridSpacing", n(4.0)), ("blendWithOriginal", n(40.0)), ("size", n(160.0))], 0.9, 0.0);
}
#[test]
fn bubbles_drizzle_mercury() {
case("ec.sim.ccbubbles", &[("bubbleAmount", n(60.0)), ("bubbleSize", n(3.0))], 0.5, 0.0);
for shading in 1..5 {
case(
"ec.sim.ccbubbles",
&[("bubbleAmount", n(40.0)), ("bubbleSize", n(4.0)), ("shadingType", e(shading)), ("reflectionType", e(shading % 2))],
0.8,
0.0,
);
}
case("ec.sim.ccdrizzle", &[("dripRate", n(12.0)), ("spreading", n(300.0))], 0.6, 0.0);
case("ec.sim.ccdrizzle", &[("dripRate", n(20.0)), ("shading/diffuse", n(40.0)), ("shading/ambient", n(-20.0)), ("light/lightHeight", n(60.0))], 1.1, 0.0);
case("ec.sim.ccmrmercury", &[("birthRate", n(4.0)), ("blobBirthSize", n(1.5)), ("blobDeathSize", n(1.0))], 1.0, 0.0);
case("ec.sim.ccmrmercury", &[("birthRate", n(3.0)), ("blobInfluence", n(60.0)), ("shading/metal", n(80.0)), ("gravity", n(2.0))], 1.5, 0.0);
}
#[test]
fn hair_wave_world_foam() {
case("ec.sim.cchair", &[("density", n(300.0)), ("length", n(10.0))], 0.0, 0.0);
case("ec.sim.cchair", &[("density", n(200.0)), ("length", n(14.0)), ("hairColor/colorInheritance", n(30.0)), ("thickness", n(1.5))], 0.0, 0.0);
case("ec.sim.waveworld", &[], 0.5, 0.0);
case("ec.sim.waveworld", &[("view", e(1))], 0.8, 0.0);
composited("ec.sim.waveworld", &[("view", e(1)), ("ground/ground", Value::Layer(Some(0))), ("heightMapControls/renderDryAreasAs", e(1))], 0.6, 0.0);
case("ec.sim.foam", &[], 1.0, 0.0);
case("ec.sim.foam", &[("view", e(2))], 1.5, 0.0);
case("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(4)), ("rendering/blendMode", e(2))], 1.2, 0.0);
}
#[test]
fn pieces() {
case("ec.sim.carddance", &[("rows", n(5.0)), ("columns", n(7.0)), ("zRotation/zRotMultiplier", n(20.0))], 0.0, 0.0);
composited("ec.sim.carddance", &[("rows", n(4.0)), ("backLayer", Value::Layer(Some(0))), ("yRotation/yRotMultiplier", n(150.0))], 0.0, 0.0);
case("ec.sim.shatter", &[("view", e(0))], 1.0, 0.0);
case("ec.sim.shatter", &[("view", e(0)), ("shape/pattern", e(2)), ("physics/gravity", n(4.0))], 1.6, 0.0);
case("ec.transition.cardwipe", &[("completion", n(40.0))], 0.0, 0.0);
composited("ec.transition.cardwipe", &[("completion", n(60.0)), ("backLayer", Value::Layer(Some(0))), ("flipAxis", e(1))], 0.0, 0.0);
}
#[test]
fn caustics() {
case("ec.sim.caustics", &[], 0.0, 0.0);
composited(
"ec.sim.caustics",
&[("water/waterSurface", Value::Layer(Some(0))), ("water/waveHeight", n(0.3)), ("water/smoothing", n(4.0)), ("lighting/lightType", e(1))],
0.0,
0.0,
);
composited(
"ec.sim.caustics",
&[
("water/waterSurface", Value::Layer(Some(0))),
("sky/sky", Value::Layer(Some(0))),
("water/surfaceOpacity", n(0.6)),
("bottom/repeatMode", e(2)),
("bottom/scaling", n(1.4)),
("bottom/blur", n(3.0)),
("sky/repeatMode", e(1)),
],
0.0,
0.0,
);
let _ = (c(0.0, 0.0, 0.0), on());
}
#[test]
fn shatter_wireframes_and_foam_extras() {
// Shatter's wireframe views (Wireframe Front View is the default) draw lines as sprites.
for view in 1..5 {
case("ec.sim.shatter", &[("view", e(view))], 1.0, 0.0);
}
case("ec.sim.shatter", &[("view", e(4)), ("force2/force2Radius", n(0.3)), ("shape/pattern", e(3))], 1.5, 0.0);
// Foam's User Defined texture, Environment Map and flow-map preview.
composited("ec.sim.foam", &[("view", e(2)), ("rendering/bubbleTexture", e(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(0)))], 1.0, 0.0);
composited(
"ec.sim.foam",
&[("view", e(2)), ("rendering/bubbleTexture", e(5)), ("rendering/bubbleTextureLayer", Value::Layer(Some(0))), ("rendering/bubbleOrientation", e(2))],
1.4,
0.0,
);
composited("ec.sim.foam", &[("view", e(2)), ("rendering/environmentMap", Value::Layer(Some(0))), ("rendering/reflectionStrength", n(0.5))], 1.2, 0.0);
composited(
"ec.sim.foam",
&[
("view", e(2)),
("rendering/environmentMap", Value::Layer(Some(0))),
("rendering/reflectionStrength", n(0.8)),
("rendering/reflectionConvergence", n(1.0)),
],
1.2,
0.0,
);
composited("ec.sim.foam", &[("view", e(1)), ("flowMap/flowMap", Value::Layer(Some(0))), ("flowMap/flowMapSteepness", n(0.5))], 1.0, 0.0);
}
+268
View File
@@ -0,0 +1,268 @@
//! GPU stylize and distort family effects vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, allow_llvmpipe_boundary_flips, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn posterize_thresholds_strobe_vignette() {
let _flips = allow_llvmpipe_boundary_flips();
effect_case("ec.stylize.posterize", &[("level", n(4.0))]);
effect_case("ec.stylize.posterize", &[("level", n(9.0))]);
effect_case("ec.stylize.threshold", &[("level", n(100.0))]);
effect_case("ec.stylize.threshold", &[("level", n(181.0))]);
effect_case("ec.stylize.ccthreshold", &[]);
effect_case("ec.stylize.ccthreshold", &[("channel", e(1)), ("invert", on()), ("threshold", n(90.0))]);
effect_case("ec.stylize.ccthreshold", &[("channel", e(2)), ("blend", n(30.0))]);
effect_case("ec.stylize.ccthreshold", &[("channel", e(3)), ("threshold", n(60.0))]);
effect_case(
"ec.stylize.ccthresholdrgb",
&[("redThreshold", n(70.0)), ("greenThreshold", n(150.0)), ("blueThreshold", n(110.0)), ("invertGreen", on()), ("blend", n(25.0))],
);
effect_case(
"ec.stylize.strobe",
&[("strobeDuration", n(0.5)), ("strobeOperator", e(1)), ("strobeColor", c(0.2, 0.5, 0.9)), ("blendWithOriginal", n(30.0))],
);
effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.5)), ("strobeOperator", e(4)), ("strobeColor", c(0.6, 0.1, 0.3))]);
effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.5)), ("strobe", e(1)), ("blendWithOriginal", n(40.0))]);
effect_case("ec.stylize.strobe", &[("strobeDuration", n(0.1)), ("strobePeriod", n(0.2)), ("randomStrobeProbability", n(50.0))]);
effect_case("ec.stylize.ccvignette", &[("amount", n(80.0))]);
effect_case("ec.stylize.ccvignette", &[("amount", n(-60.0)), ("pinHighlights", n(50.0)), ("angleOfView", n(120.0)), ("center", pt(20.0, 15.0))]);
}
#[test]
fn scatter_brush_strokes() {
effect_case("ec.stylize.scatter", &[("amount", n(5.0))]);
effect_case("ec.stylize.scatter", &[("amount", n(3.0)), ("grain", e(1))]);
effect_case("ec.stylize.scatter", &[("amount", n(7.0)), ("grain", e(2)), ("randomizeEveryFrame", on())]);
effect_case("ec.stylize.brushstrokes", &[]);
effect_case(
"ec.stylize.brushstrokes",
&[("strokeAngle", n(40.0)), ("brushSize", n(3.0)), ("strokeLength", n(10.0)), ("paintSurface", e(2)), ("blendWithOriginal", n(20.0))],
);
effect_case("ec.stylize.brushstrokes", &[("paintSurface", e(1)), ("strokeDensity", n(0.6)), ("strokeRandomness", n(2.0)), ("brushSize", n(1.3))]);
effect_case("ec.stylize.brushstrokes", &[("paintSurface", e(3)), ("strokeLength", n(25.0))]);
}
#[test]
fn roughen_edges() {
effect_case("ec.stylize.roughenedges", &[]);
effect_case("ec.stylize.roughenedges", &[("edgeType", e(1)), ("border", n(4.0)), ("edgeColor", c(0.1, 0.6, 0.9))]);
effect_case(
"ec.stylize.roughenedges",
&[
("edgeType", e(5)),
("evolutionOptions/cycleEvolution", on()),
("evolutionOptions/cycle", n(2.0)),
("evolution", n(500.0)),
("stretch", n(2.0)),
("complexity", n(3.0)),
],
);
effect_case("ec.stylize.roughenedges", &[("edgeType", e(7)), ("border", n(0.0)), ("scale", n(60.0)), ("evolutionOptions/randomSeed", n(9.0))]);
}
#[test]
fn tiles_kaleida_repetile() {
effect_case("ec.stylize.motiontile", &[("tileWidth", n(50.0)), ("tileHeight", n(40.0))]);
effect_case(
"ec.stylize.motiontile",
&[("outputWidth", n(150.0)), ("outputHeight", n(130.0)), ("mirrorEdges", on()), ("phase", n(70.0)), ("tileWidth", n(45.0))],
);
effect_case(
"ec.stylize.motiontile",
&[("horizontalPhaseShift", on()), ("phase", n(33.0)), ("tileCenter", pt(30.0, 17.0)), ("tileHeight", n(55.0)), ("outputWidth", n(80.0))],
);
// Output under 100 % on an adjustment crops the comp below (#1727).
effect_case("ec.stylize.motiontile", &[("outputWidth", n(60.0)), ("outputHeight", n(300.0)), ("mirrorEdges", on())]);
effect_case("ec.stylize.cckaleida", &[]);
effect_case("ec.stylize.cckaleida", &[("mirroring", e(2)), ("rotation", n(20.0)), ("size", n(150.0))]);
effect_case("ec.stylize.ccrepetile", &[("expandRight", n(10.0)), ("expandUp", n(4.0))]);
effect_case("ec.stylize.ccrepetile", &[("expandLeft", n(12.0)), ("expandDown", n(9.0)), ("tiling", e(1))]);
effect_case("ec.stylize.ccrepetile", &[("expandLeft", n(30.0)), ("expandRight", n(30.0)), ("expandUp", n(20.0)), ("tiling", e(4))]);
effect_case("ec.distort.cctiler", &[]);
effect_case("ec.distort.cctiler", &[("scale", n(70.0)), ("blendWithOriginal", n(30.0)), ("center", pt(30.0, 18.0))]);
effect_case("ec.distort.ccgriddler", &[("rotation", n(20.0))]);
effect_case("ec.distort.ccgriddler", &[("cutTiles", on()), ("horizontalScale", n(120.0)), ("verticalScale", n(80.0)), ("tileSize", n(17.0))]);
}
#[test]
fn mirror_offset_polar_spherize_corner_pin() {
effect_case("ec.distort.mirror", &[("angle", n(30.0))]);
effect_case("ec.distort.mirror", &[("angle", n(200.0)), ("center", pt(27.0, 19.0))]);
effect_case("ec.distort.offset", &[("shift", pt(10.0, 5.0))]);
effect_case("ec.distort.offset", &[("shift", pt(50.3, 20.7)), ("blend", n(40.0))]);
effect_case("ec.distort.polar", &[("interpolation", n(100.0))]);
effect_case("ec.distort.polar", &[("interpolation", n(60.0)), ("conversion", e(1))]);
effect_case("ec.distort.spherize", &[("radius", n(20.0))]);
effect_case("ec.distort.spherize", &[("radius", n(35.0)), ("center", pt(30.0, 25.0))]);
effect_case("ec.distort.cornerpin", &[("ul", pt(5.0, 3.0)), ("ur", pt(60.0, 8.0)), ("lr", pt(66.0, 40.0)), ("ll", pt(2.0, 44.0))]);
effect_case("ec.distort.cornerpin", &[("ul", pt(-8.0, 6.0)), ("ur", pt(75.0, -4.0)), ("lr", pt(50.0, 30.0)), ("ll", pt(12.0, 50.0))]);
}
#[test]
fn optics_magnify() {
effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(60.0))]);
effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(90.0)), ("reverseLensDistortion", on()), ("fovOrientation", e(2))]);
effect_case(
"ec.distort.opticscompensation",
&[("fieldOfView", n(110.0)), ("reverseLensDistortion", on()), ("resize", e(1)), ("viewCenter", pt(30.0, 20.0))],
);
effect_case("ec.distort.opticscompensation", &[("fieldOfView", n(120.0)), ("optimalPixels", on()), ("fovOrientation", e(1))]);
effect_case("ec.distort.magnify", &[("size", n(15.0))]);
effect_case(
"ec.distort.magnify",
&[("shape", e(1)), ("feather", n(6.0)), ("size", n(18.0)), ("scaling", e(1)), ("blendingMode", e(3)), ("magnification", n(270.0))],
);
effect_case("ec.distort.magnify", &[("scaling", e(2)), ("blendingMode", e(0)), ("size", n(20.0)), ("opacity", n(70.0))]);
effect_case("ec.distort.magnify", &[("size", n(25.0)), ("center", pt(60.0, 10.0)), ("resizeLayer", on()), ("blendingMode", e(5))]);
}
#[test]
fn cc_slant_smear_split() {
effect_case("ec.distort.ccslant", &[("slant", n(30.0))]);
effect_case("ec.distort.ccslant", &[("slant", n(-20.0)), ("stretching", on()), ("height", n(120.0))]);
effect_case("ec.distort.ccsmear", &[]);
effect_case("ec.distort.ccsmear", &[("reach", n(-40.0)), ("radius", n(20.0)), ("to", pt(40.0, 10.0))]);
effect_case("ec.distort.ccsplit", &[]);
effect_case("ec.distort.ccsplit2", &[("split1", n(30.0)), ("split2", n(80.0)), ("pointB", pt(60.0, 30.0))]);
}
#[test]
fn twirl_legacy_ripple_pulse_power_pin() {
effect_case("ec.distort.twirllegacy", &[("angle", n(90.0))]);
effect_case("ec.distort.twirllegacy", &[("angle", n(-250.0)), ("radius", n(80.0)), ("center", pt(30.0, 18.0))]);
effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(80.0)), ("timeSpan", n(0.5))]);
effect_case("ec.distort.ccripplepulse", &[("pulseLevel", n(-60.0)), ("amplitude", n(150.0)), ("renderBump", on()), ("center", pt(25.0, 20.0))]);
effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(5.0, 3.0)), ("bottomRight", pt(62.0, 38.0))]);
effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("expandLeft", n(20.0)), ("expandBottom", n(-10.0))]);
// Perspective below 100 %: blended toward the bilinear inverse.
effect_case("ec.distort.ccpowerpin", &[("topRight", pt(80.0, -6.0)), ("bottomLeft", pt(-4.0, 50.0)), ("perspective", n(0.0))]);
effect_case("ec.distort.ccpowerpin", &[("topLeft", pt(12.0, 6.0)), ("bottomRight", pt(75.0, 30.0)), ("perspective", n(40.0)), ("expandTop", n(15.0))]);
}
#[test]
fn flo_motion() {
effect_case("ec.distort.ccflomotion", &[]);
effect_case("ec.distort.ccflomotion", &[("antialiasing", e(2)), ("tileEdges", on()), ("amount1", n(-150.0)), ("falloff", n(0.7))]);
effect_case("ec.distort.ccflomotion", &[("antialiasing", e(0)), ("amount2", n(0.0)), ("knot1", pt(20.0, 30.0))]);
}
#[test]
fn liquify_render() {
let mesh = "warp 20 50 0 0 0 10,10 20,15 30,20\ntwirlClockwise 24 80 0 0 0 40,25 42,27";
effect_case("ec.distort.liquify", &[("distortionMesh", Value::Str(mesh.into()))]);
effect_case(
"ec.distort.liquify",
&[("distortionMesh", Value::Str(mesh.into())), ("distortionPercentage", n(60.0)), ("distortionMeshOffset", pt(3.0, -2.0))],
);
}
#[test]
fn glow_operations_and_arbitrary_map() {
effect_case("ec.stylize.glow", &[("glowOperation", e(2)), ("threshold", n(30.0)), ("radius", n(8.0))]);
effect_case("ec.stylize.glow", &[("glowOperation", e(3)), ("colors", e(1)), ("intensity", n(2.0))]);
effect_case("ec.stylize.glow", &[("colors", e(2)), ("arbitraryMap", Value::Str("0,0 0.5,0.8 1,1|0,0.2 1,0.6|".into())), ("threshold", n(20.0))]);
effect_case("ec.stylize.glow", &[("colors", e(2)), ("arbitraryMap", Value::Str("0,1 1,0||0,0 1,1".into())), ("operation", e(1)), ("glowOperation", e(4))]);
}
/// Texturize with a texture layer (Tile / Center / Stretch), composited.
#[test]
fn texturize_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for placement in 0..3 {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut tex = s.footage(50, 36);
tex.switches.video = false;
let tid = s.push(tex);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.stylize.texturize",
&[("textureLayer", Value::Layer(Some(tid.0))), ("texturePlacement", e(placement)), ("textureContrast", n(1.5)), ("lightDirection", n(60.0))],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("texturize {placement} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
/// Transform's own shutter angle: the effect's rotation and position animate.
#[test]
fn transform_motion_blur() {
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, "ec.distort.transform", &[("useCompositionShutterAngle", Value::Bool(false)), ("shutterAngle", n(180.0))]);
let rot = l.props.prop_mut("effects/#1/rotation").expect("transform rotation");
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(120.0))];
let pos = l.props.prop_mut("effects/#1/position").expect("transform position");
pos.keys = vec![Keyframe::new(Tick::ZERO, pt(35.0, 22.0)), Keyframe::new(Tick::from_seconds_f64(1.0), pt(95.0, 40.0))];
s.push(l);
let label = format!("transform motion blur {depth:?}");
check(&label, compare_at(&s, opts(), Tick::from_seconds_f64(0.3)), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::from_seconds_f64(0.3)), 0.0);
}
}
/// Motion Tile's Output size on an adjustment layer with a Transform after it that scales the
/// tiles into view, and cropping (Output under 100 %): the stack's grown or cropped frame goes
/// back onto the canvas as on the CPU (#1727).
#[test]
fn adjustment_motion_tile_output_then_transform() {
for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0), (60.0, 80.0, 100.0)] {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut adj = s.solid([1.0, 1.0, 1.0], 97, 61);
adj.switches.adjustment = true;
s.effect(&mut adj, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]);
s.effect(&mut adj, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]);
s.push(adj);
let label = format!("adjustment Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
/// The same on a full-frame footage layer (#1991).
#[test]
fn layer_motion_tile_output_then_transform() {
for (out_w, out_h, scale) in [(300.0, 300.0, 40.0), (250.0, 120.0, 70.0)] {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.footage(97, 61);
s.effect(&mut l, "ec.stylize.motiontile", &[("outputWidth", n(out_w)), ("outputHeight", n(out_h)), ("mirrorEdges", on())]);
s.effect(&mut l, "ec.distort.transform", &[("scaleHeight", n(scale)), ("scaleWidth", n(scale))]);
s.push(l);
let label = format!("layer Motion Tile {out_w}×{out_h} % then Scale {scale} % {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
//! GPU Numbers and Timecode vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use crate::tests::{c, effect_case, n};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn numbers() {
effect_case("ec.text.numbers", &[("format/value", n(42.5)), ("size", n(14.0))]);
effect_case(
"ec.text.numbers",
&[("format/value", n(-7.0)), ("size", n(12.0)), ("fillAndStroke/displayOptions", e(2)), ("fillAndStroke/strokeWidth", n(1.5)), ("tracking", n(2.0))],
);
effect_case(
"ec.text.numbers",
&[
("format/value", n(3.25)),
("size", n(16.0)),
("fillAndStroke/displayOptions", e(3)),
("fillAndStroke/fillColor", c(0.2, 0.6, 1.0)),
("compositeOnOriginal", Value::Bool(false)),
],
);
effect_case("ec.text.numbers", &[("size", n(10.0)), ("fillAndStroke/displayOptions", e(1)), ("fillAndStroke/position", pt(20.0, 15.0))]);
}
#[test]
fn timecode() {
effect_case("ec.text.timecode", &[("textSize", n(10.0)), ("textPosition", pt(35.0, 22.0))]);
effect_case(
"ec.text.timecode",
&[("textSize", n(9.0)), ("displayFormat", e(1)), ("renderOnOriginal", Value::Bool(false)), ("textColor", c(1.0, 0.8, 0.1))],
);
effect_case("ec.text.timecode", &[("textSize", n(8.0)), ("showBox", Value::Bool(true)), ("boxColor", c(0.1, 0.1, 0.4)), ("opacity", n(70.0))]);
}
+123
View File
@@ -0,0 +1,123 @@
//! GPU time effects (Time Difference, Time Displacement, CC Force Motion Blur, CC Wide Time,
//! Pixel Motion Blur, Timewarp) vs the CPU effects (the oracle). They fetch other frames through
//! the effect host: a precomp whose content moves, at a nonzero time, composited at 8 and
//! 32 bpc (the direct cases have no host and pass the layer through).
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set, v3};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
/// `id` with `vals` on a moving precomp; `map` adds a hidden footage layer and sets parameter
/// `map` to it.
fn moving(id: &str, vals: &[(&str, Value)], map: Option<&str>) {
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 vals = vals.to_vec();
if let Some(param) = map {
let mut m = s.footage(50, 36);
m.switches.video = false;
let mid = s.push(m);
vals.push((param, Value::Layer(Some(mid.0))));
}
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.into()));
let mut a = s.footage(30, 24);
let pos = a.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, v3(10.0, 12.0)), Keyframe::new(Tick::from_seconds_f64(1.0), v3(50.0, 30.0))];
let rot = a.props.prop_mut("transform/rotation").unwrap();
rot.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(0.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(90.0))];
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
s.effect(&mut l, id, &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let t = Tick::from_seconds_f64(0.4);
let label = format!("{id} {vals:?} {depth:?}");
check(&label, compare_at(&s, opts(), t), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
}
}
#[test]
fn without_a_host() {
for id in
["ec.time.timedifference", "ec.time.timedisplacement", "ec.time.ccforcemotionblur", "ec.time.ccwidetime", "ec.time.pixelmotionblur", "ec.time.timewarp"]
{
effect_case(id, &[]);
}
}
#[test]
fn frame_averages() {
moving("ec.time.ccforcemotionblur", &[], None);
moving("ec.time.ccforcemotionblur", &[("motionBlurLevels", n(5.0)), ("shutterAngle", n(400.0))], None);
moving("ec.time.ccwidetime", &[], None);
moving("ec.time.ccwidetime", &[("forwardSteps", n(1.0)), ("backwardSteps", n(4.0))], None);
moving("ec.time.pixelmotionblur", &[], None);
moving("ec.time.pixelmotionblur", &[("shutterControl", e(1)), ("shutterAngle", n(300.0)), ("shutterSamples", n(5.0))], None);
}
#[test]
fn time_difference() {
moving("ec.time.timedifference", &[("timeOffset", n(-0.1))], None);
for alpha in 0..9 {
moving(
"ec.time.timedifference",
&[("timeOffset", n(0.2)), ("contrast", n(40.0)), ("absoluteDifference", Value::Bool(alpha % 2 == 0)), ("alphaChannel", e(alpha))],
None,
);
}
moving("ec.time.timedifference", &[("timeOffset", n(0.1)), ("alphaChannel", e(2))], Some("targetLayer"));
}
#[test]
fn time_displacement() {
moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.3)), ("timeResolution", n(10.0))], None);
moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(0.5)), ("timeResolution", n(30.0))], Some("displacementMapLayer"));
moving("ec.time.timedisplacement", &[("maxDisplacementTime", n(2.0)), ("stretchMap", Value::Bool(false))], Some("displacementMapLayer"));
}
#[test]
fn timewarp() {
for method in 0..3 {
moving("ec.time.timewarp", &[("method", e(method)), ("speed", n(37.0))], None);
}
moving("ec.time.timewarp", &[("method", e(1)), ("adjustTimeBy", e(1)), ("sourceFrame", n(7.4))], None);
moving("ec.time.timewarp", &[("method", e(2)), ("speed", n(43.0)), ("motionBlur/enableMotionBlur", on()), ("motionBlur/shutterSamples", n(3.0))], None);
moving(
"ec.time.timewarp",
&[
("speed", n(61.0)),
("tuning/filtering", e(1)),
("tuning/errorThreshold", n(30.0)),
("sourceCrops/leftCrop", n(4.0)),
("sourceCrops/topCrop", n(3.0)),
],
None,
);
moving("ec.time.timewarp", &[("speed", n(55.0)), ("tuning/buildFromOneImage", on())], None);
moving("ec.time.timewarp", &[("speed", n(47.0))], Some("warpLayer"));
// A Matte Layer: every method and Show, the luminance channels, motion blur.
for method in 0..3 {
for show in 0..4 {
moving("ec.time.timewarp", &[("method", e(method)), ("speed", n(47.0)), ("show", e(show))], Some("matteLayer"));
}
}
moving("ec.time.timewarp", &[("speed", n(39.0)), ("matteChannel", e(2))], Some("matteLayer"));
moving("ec.time.timewarp", &[("speed", n(43.0)), ("matteChannel", e(1)), ("motionBlur/enableMotionBlur", on())], Some("matteLayer"));
moving("ec.time.timewarp", &[("method", e(1)), ("speed", n(52.0)), ("tuning/buildFromOneImage", on()), ("show", e(1))], Some("matteLayer"));
}
+190
View File
@@ -0,0 +1,190 @@
//! GPU colour-correction family effects vs the CPU effects (the oracle): direct on a buffer at
//! full and half resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_color::Label;
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::{BitDepth, Comp, ItemKind, LayerSource, build};
use effectcraft_render::RenderOpts;
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn off() -> Value {
Value::Bool(false)
}
#[test]
fn levels_and_gamma() {
effect_case(
"ec.color.levelsic",
&[("rgb/rgbInBlack", n(0.1)), ("rgb/rgbGamma", n(1.3)), ("red/redOutWhite", n(0.8)), ("blue/blueInWhite", n(0.7)), ("alpha/alphaGamma", n(0.7))],
);
effect_case("ec.color.levelsic", &[("green/greenInBlack", n(0.2)), ("green/greenInWhite", n(0.6)), ("clipToOutputWhite", e(0))]);
effect_case("ec.color.levelsic", &[("alpha/alphaOutBlack", n(0.3))]);
effect_case("ec.color.levels", &[("redInBlack", n(0.1)), ("greenGamma", n(1.5)), ("blueOutWhite", n(0.7)), ("inWhite", n(0.9))]);
effect_case("ec.color.levels", &[("alphaInWhite", n(0.6)), ("redInWhite", n(0.5)), ("redClipToOutputWhite", e(0))]);
effect_case("ec.color.gammapedestalgain", &[("redGamma", n(1.4)), ("greenPedestal", n(0.1)), ("blueGain", n(0.8))]);
effect_case("ec.color.gammapedestalgain", &[("blackStretch", n(2.0)), ("redGain", n(1.2))]);
}
#[test]
fn hue_saturation_ranges() {
effect_case("ec.color.huesaturation", &[("redsHue", n(40.0)), ("bluesSaturation", n(-60.0)), ("greensLightness", n(30.0))]);
effect_case(
"ec.color.huesaturation",
&[
("hue", n(20.0)),
("saturation", n(15.0)),
("yellowsSaturation", n(50.0)),
("yellowsRangeStart", n(30.0)),
("yellowsStartFalloff", n(8.0)),
("cyansLightness", n(-40.0)),
],
);
}
#[test]
fn colour_replacement() {
effect_case("ec.color.photofilter", &[]);
effect_case(
"ec.color.photofilter",
&[("filter", e(effectcraft_effects::PHOTO_FILTER_CUSTOM)), ("color", c(0.2, 0.7, 0.4)), ("density", n(60.0)), ("preserveLuminosity", off())],
);
effect_case("ec.color.changetocolor", &[("from", c(0.8, 0.4, 0.3)), ("to", c(0.2, 0.5, 0.9)), ("toleranceGroup/hue", n(15.0))]);
effect_case(
"ec.color.changetocolor",
&[("from", c(0.6, 0.6, 0.3)), ("to", c(0.9, 0.2, 0.6)), ("change", e(3)), ("changeBy", e(1)), ("toleranceGroup/hue", n(25.0)), ("softness", n(30.0))],
);
effect_case("ec.color.changetocolor", &[("from", c(0.6, 0.6, 0.3)), ("change", e(2)), ("toleranceGroup/hue", n(25.0)), ("viewCorrectionMatte", on())]);
effect_case("ec.color.leavecolor", &[("amount", n(80.0)), ("color", c(0.7, 0.5, 0.4)), ("tolerance", n(10.0)), ("edgeSoftness", n(20.0))]);
effect_case(
"ec.color.leavecolor",
&[("amount", n(100.0)), ("color", c(0.8, 0.4, 0.3)), ("matchColors", e(1)), ("tolerance", n(5.0)), ("edgeSoftness", n(30.0))],
);
effect_case("ec.color.changecolor", &[("hueTransform", n(90.0)), ("colorToChange", c(0.7, 0.5, 0.4)), ("matchingSoftness", n(20.0))]);
effect_case(
"ec.color.changecolor",
&[
("lightnessTransform", n(-30.0)),
("saturationTransform", n(40.0)),
("matchColors", e(0)),
("matchingTolerance", n(20.0)),
("matchingSoftness", n(15.0)),
],
);
effect_case("ec.color.changecolor", &[("view", e(1)), ("matchColors", e(2)), ("matchingSoftness", n(25.0)), ("invertColorCorrectionMask", on())]);
}
#[test]
fn broadcast_balance_limiter() {
effect_case("ec.color.broadcast", &[("maxSignal", n(95.0))]);
effect_case("ec.color.broadcast", &[("locale", e(1)), ("howToMakeSafe", e(1)), ("maxSignal", n(90.0))]);
effect_case("ec.color.colorbalancehls", &[("hue", n(40.0)), ("lightness", n(-20.0)), ("saturation", n(30.0))]);
effect_case("ec.color.colorbalancehls", &[("lightness", n(25.0)), ("saturation", n(-50.0))]);
effect_case("ec.color.videolimiter", &[("clipLevel", e(0))]);
effect_case("ec.color.videolimiter", &[("clipLevel", e(1)), ("clipMethod", e(1)), ("compressionBeforeClipping", e(4))]);
effect_case("ec.color.videolimiter", &[("clipLevel", e(0)), ("clipMethod", e(0)), ("gamutWarning", on())]);
}
#[test]
fn cc_colour_and_arbitrary_map() {
effect_case("ec.color.cctoner", &[]);
effect_case("ec.color.cctoner", &[("tones", e(2)), ("blend", n(30.0))]);
effect_case("ec.color.cctoner", &[("tones", e(0)), ("shadows", c(0.1, 0.0, 0.3)), ("highlights", c(1.0, 0.9, 0.6))]);
effect_case("ec.color.cccoloroffset", &[("redPhase", n(90.0)), ("bluePhase", n(200.0))]);
effect_case("ec.color.cccoloroffset", &[("greenPhase", n(120.0)), ("overflow", e(1))]);
effect_case("ec.color.cckernel", &[("k1", n(-1.0)), ("k5", n(5.0)), ("k9", n(-1.0)), ("scale", n(0.5)), ("offset", n(0.05))]);
effect_case("ec.color.cckernel", &[("k2", n(1.0)), ("k5", n(0.0)), ("k8", n(-1.0)), ("absolute", on())]);
let map: String = (0..256).map(|i| format!("{}", ((i as f64 / 255.0).powf(0.6) * 255.0).round())).collect::<Vec<_>>().join(",");
effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map.clone()))]);
effect_case("ec.color.psarbitrarymap", &[("map", Value::Str(map)), ("phase", n(40.0)), ("applyPhaseMapToAlpha", on())]);
}
#[test]
fn automatic_corrections() {
effect_case("ec.color.autolevels", &[]);
effect_case("ec.color.autolevels", &[("blackClip", n(2.0)), ("whiteClip", n(3.0)), ("blend", n(20.0))]);
effect_case("ec.color.autocontrast", &[("blackClip", n(1.0))]);
effect_case("ec.color.autocolor", &[]);
effect_case("ec.color.autocolor", &[("snapNeutralMidtones", on()), ("whiteClip", n(2.0))]);
effect_case("ec.color.equalize", &[]);
effect_case("ec.color.equalize", &[("style", e(1)), ("amount", n(70.0))]);
effect_case("ec.color.equalize", &[("style", e(2))]);
}
#[test]
fn shadow_highlight_neutralizer_invert() {
effect_case("ec.color.shadowhighlight", &[]);
effect_case(
"ec.color.shadowhighlight",
&[
("autoAmounts", off()),
("shadowAmount", n(70.0)),
("highlightAmount", n(40.0)),
("moreOptions/shadowRadius", n(8.0)),
("moreOptions/highlightRadius", n(15.0)),
("moreOptions/midtoneContrast", n(30.0)),
("moreOptions/blackClip", n(2.0)),
("blend", n(20.0)),
],
);
effect_case("ec.color.shadowhighlight", &[("moreOptions/blackClip", n(0.0)), ("moreOptions/whiteClip", n(0.0)), ("moreOptions/colorCorrection", n(-40.0))]);
effect_case(
"ec.color.cccolorneutralizer",
&[("shadowsBalance", c(0.1, 0.0, 0.05)), ("midtonesUnbalance", c(0.6, 0.5, 0.4)), ("pinning", n(40.0)), ("contrast", n(20.0)), ("darks", n(-30.0))],
);
effect_case("ec.color.cccolorneutralizer", &[("highlightsBalance", c(0.9, 1.0, 0.8)), ("brights", n(40.0)), ("blendWOriginal", n(25.0))]);
for ch in 4..=11 {
effect_case("ec.channel.invert", &[("channel", e(ch)), ("blend", n(20.0))]);
}
}
/// Color Stabilizer reads its reference frame through the effect host: a precomp whose content
/// moves, at a nonzero time (the direct cases have no host and pass the layer through).
#[test]
fn color_stabilizer() {
effect_case("ec.color.colorstabilizer", &[]);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for mode in [0, 1, 2] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
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.into()));
let mut a = s.footage(50, 34);
let pos = a.props.prop_mut("transform/position").unwrap();
pos.keys = vec![Keyframe::new(Tick::ZERO, crate::tests::v3(22.0, 18.0)), Keyframe::new(Tick::from_seconds_f64(1.0), crate::tests::v3(38.0, 24.0))];
let op = a.props.prop_mut("transform/opacity").unwrap();
op.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(100.0)), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Scalar(40.0))];
s.p.comp_mut(inner_id).unwrap().layers = vec![a];
let mut l = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (60, 40), None);
let vals = [
("stabilize", e(mode)),
("blackPoint", Value::Vec2([18.0, 20.0])),
("midPoint", Value::Vec2([30.0, 18.0])),
("whitePoint", Value::Vec2([42.0, 22.0])),
("sampleSize", n(4.0)),
];
s.effect(&mut l, "ec.color.colorstabilizer", &vals);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let t = Tick::from_seconds_f64(0.5);
let label = format!("color stabilizer {mode} {depth:?}");
check(&label, compare_at(&s, opts(), t), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, t), 0.0);
}
}
}
#[test]
fn gradient_ramp_matches_the_cpu() {
effect_case("ec.generate.gradientramp", &[]);
}
+163
View File
@@ -0,0 +1,163 @@
//! GPU transition and perspective effects (Block Dissolve, the CC transitions, Radial Shadow,
//! CC Bender, CC Blobbylize, CC Cylinder, CC Sphere, CC Spotlight, CC Environment, 3D Glasses)
//! vs the CPU effects (the oracle): direct on a buffer at full and half resolution, as
//! adjustment, and composited at 8 and 32 bpc; effects reading other layers also with a layer
//! chosen.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn block_dissolve() {
effect_case("ec.transition.blockdissolve", &[("completion", n(40.0))]);
effect_case("ec.transition.blockdissolve", &[("completion", n(55.0)), ("blockWidth", n(7.0)), ("blockHeight", n(5.5)), ("feather", n(3.0))]);
effect_case("ec.transition.blockdissolve", &[("completion", n(30.0)), ("blockWidth", n(4.3)), ("blockHeight", n(9.0)), ("softEdges", Value::Bool(false))]);
}
#[test]
fn grid_and_scale_wipes() {
effect_case("ec.transition.ccgridwipe", &[("completion", n(40.0)), ("center", pt(33.0, 21.0))]);
effect_case("ec.transition.ccgridwipe", &[("completion", n(60.0)), ("rotation", n(20.0)), ("border", n(3.0)), ("tiles", n(7.0)), ("shape", e(0))]);
effect_case("ec.transition.ccgridwipe", &[("completion", n(35.0)), ("shape", e(2)), ("reverse", on()), ("tiles", n(5.0))]);
effect_case("ec.transition.ccradialscalewipe", &[("completion", n(40.0)), ("center", pt(30.0, 20.0))]);
effect_case("ec.transition.ccradialscalewipe", &[("completion", n(30.0)), ("center", pt(40.0, 25.0)), ("reverse", on())]);
effect_case("ec.transition.ccscalewipe", &[("stretch", n(2.0)), ("center", pt(30.0, 20.0))]);
effect_case("ec.transition.ccscalewipe", &[("stretch", n(0.7)), ("direction", n(200.0))]);
}
#[test]
fn gradient_driven_wipes() {
effect_case("ec.transition.ccglasswipe", &[("completion", n(45.0))]);
effect_case("ec.transition.ccglasswipe", &[("completion", n(30.0)), ("softness", n(40.0)), ("displacementAmount", n(25.0))]);
effect_case("ec.transition.ccimagewipe", &[("completion", n(50.0))]);
effect_case(
"ec.transition.ccimagewipe",
&[("completion", n(40.0)), ("property", e(5)), ("blur", n(4.0)), ("inverseGradient", on()), ("borderSoftness", n(20.0))],
);
effect_case("ec.transition.ccimagewipe", &[("completion", n(60.0)), ("property", e(6)), ("autoSoftness", Value::Bool(false))]);
effect_case("ec.transition.ccwarpomatic", &[("completion", n(40.0))]);
effect_case("ec.transition.ccwarpomatic", &[("completion", n(55.0)), ("reactor", e(1)), ("warpDirection", e(1))]);
effect_case("ec.transition.ccwarpomatic", &[("completion", n(35.0)), ("reactor", e(2)), ("warpDirection", e(2)), ("blendSpan", n(30.0))]);
}
#[test]
fn geometric_transitions() {
effect_case("ec.transition.ccjaws", &[("completion", n(30.0)), ("center", pt(33.0, 21.0))]);
for shape in 1..4 {
effect_case("ec.transition.ccjaws", &[("completion", n(20.0)), ("shape", e(shape)), ("direction", n(25.0)), ("width", n(13.0))]);
}
effect_case("ec.transition.cclinesweep", &[("completion", n(40.0))]);
effect_case(
"ec.transition.cclinesweep",
&[("completion", n(50.0)), ("direction", n(35.0)), ("slant", n(-12.0)), ("thickness", n(7.0)), ("flipDirection", on())],
);
effect_case("ec.transition.cctwister", &[("completion", n(30.0)), ("center", pt(33.0, 21.0))]);
effect_case("ec.transition.cctwister", &[("completion", n(60.0)), ("axis", n(10.0)), ("shading", Value::Bool(false))]);
}
#[test]
fn perspective() {
effect_case("ec.perspective.radialshadow", &[("lightSource", pt(20.0, 10.0)), ("projectionDistance", n(15.0))]);
effect_case(
"ec.perspective.radialshadow",
&[
("lightSource", pt(50.0, 15.0)),
("softness", n(6.0)),
("render", e(1)),
("colorInfluence", n(60.0)),
("resizeLayer", on()),
("color", c(0.3, 0.0, 0.5)),
],
);
effect_case("ec.perspective.radialshadow", &[("shadowOnly", on()), ("opacity", n(80.0)), ("resizeLayer", on())]);
effect_case("ec.perspective.cccylinder", &[("position", pt(35.0, 22.0)), ("rotation", n(30.0))]);
effect_case("ec.perspective.cccylinder", &[("radius", n(60.0)), ("render", e(1)), ("lightDirection", n(70.0)), ("roughness", n(0.2))]);
effect_case("ec.perspective.cccylinder", &[("render", e(2)), ("lightHeight", n(20.0)), ("specular", n(60.0))]);
// All three axes: independently and combined, against the CPU reference.
effect_case("ec.perspective.cccylinder", &[("rotationX", n(35.0)), ("render", e(1))]);
effect_case("ec.perspective.cccylinder", &[("rotationY", n(65.0)), ("rotation", n(15.0))]);
effect_case("ec.perspective.cccylinder", &[("rotationZ", n(-25.0)), ("render", e(2))]);
effect_case("ec.perspective.cccylinder", &[("rotationX", n(30.0)), ("rotationY", n(20.0)), ("rotationZ", n(15.0))]);
effect_case("ec.perspective.ccsphere", &[("offset", pt(35.0, 22.0)), ("radius", n(18.0)), ("rotationX", n(20.0)), ("rotationY", n(35.0))]);
effect_case("ec.perspective.ccsphere", &[("radius", n(20.0)), ("rotationZ", n(50.0)), ("render", e(2))]);
effect_case("ec.perspective.ccspotlight", &[("from", pt(15.0, 8.0)), ("to", pt(35.0, 22.0))]);
effect_case(
"ec.perspective.ccspotlight",
&[("from", pt(50.0, 5.0)), ("to", pt(30.0, 30.0)), ("coneAngle", n(35.0)), ("edgeSoftness", n(60.0)), ("render", e(1))],
);
effect_case("ec.perspective.ccspotlight", &[("to", pt(35.0, 22.0)), ("edgeSoftness", n(0.0)), ("color", c(1.0, 0.7, 0.3)), ("intensity", n(150.0))]);
for mapping in 0..3 {
effect_case("ec.perspective.ccenvironment", &[("mapping", e(mapping)), ("height", n(40.0))]);
}
effect_case("ec.perspective.ccenvironment", &[("filterEnvironment", Value::Bool(false))]);
for view in 0..9 {
effect_case("ec.perspective.3dglasses", &[("view3d", e(view)), ("sceneConvergence", n(4.0)), ("verticalAlignment", n(1.5))]);
}
effect_case("ec.perspective.3dglasses", &[("units", e(1)), ("sceneConvergence", n(3.0)), ("leftRightSwap", on()), ("balance", n(12.0))]);
}
#[test]
fn distort() {
effect_case("ec.distort.ccbender", &[("amount", n(40.0)), ("top", pt(35.0, 0.0)), ("base", pt(35.0, 44.0))]);
for style in 1..4 {
effect_case(
"ec.distort.ccbender",
&[("amount", n(-30.0)), ("style", e(style)), ("top", pt(20.0, 5.0)), ("base", pt(40.0, 40.0)), ("adjustToDistance", on())],
);
}
effect_case("ec.distort.ccblobbylize", &[]);
effect_case(
"ec.distort.ccblobbylize",
&[("property", e(4)), ("softness", n(3.0)), ("cutAway", n(30.0)), ("lightType", e(1)), ("lightPosition", pt(20.0, 10.0))],
);
effect_case("ec.distort.ccblobbylize", &[("cutAway", n(100.0))]);
effect_case("ec.distort.ccblobbylize", &[("property", e(5)), ("metal", n(30.0)), ("roughness", n(0.2))]);
}
/// Effects reading another layer (gradient, reveal, backside, environment, stereo views).
#[test]
fn with_other_layers() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let cases: Vec<(&str, Vec<(&str, Value)>)> = vec![
("ec.transition.ccglasswipe", vec![("completion", n(40.0)), ("gradientLayer", Value::Layer(None)), ("layerToReveal", Value::Layer(None))]),
("ec.transition.ccimagewipe", vec![("completion", n(45.0)), ("layer", Value::Layer(None))]),
("ec.transition.ccwarpomatic", vec![("completion", n(40.0)), ("reactorLayer", Value::Layer(None)), ("layerToReveal", Value::Layer(None))]),
("ec.transition.cctwister", vec![("completion", n(70.0)), ("backside", Value::Layer(None))]),
("ec.perspective.ccenvironment", vec![("environment", Value::Layer(None))]),
("ec.perspective.3dglasses", vec![("view3d", e(5)), ("leftView", Value::Layer(None)), ("sceneConvergence", n(3.0))]),
("ec.distort.ccblobbylize", vec![("blobLayer", Value::Layer(None))]),
];
for (id, vals) in cases {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut other = s.footage(50, 36);
other.switches.video = false;
let oid = s.push(other);
let vals: Vec<(&str, Value)> = vals.into_iter().map(|(k, v)| (k, if v == Value::Layer(None) { Value::Layer(Some(oid.0)) } else { v })).collect();
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);
let label = format!("{id} with a layer {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
+154
View File
@@ -0,0 +1,154 @@
//! GPU Immersive Video family vs the CPU effects (the oracle): direct on a buffer (full and half
//! resolution, as adjustment) and composited at 8 and 32 bpc, in mono and both stereo layouts.
//!
//! Exception: re-projections that cross a cube-map face edge or a fisheye rim may pick the
//! neighbouring face / side for directions within f32 rounding of the edge (the CPU decides in
//! f64), so up to 0.5 % of the pixels of those cases may differ.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, diff, gpu, n, opts, pattern, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
/// The effect alone on a buffer (`allow` = fraction of pixels allowed over 1e-3).
fn direct(id: &str, vals: &[(&str, Value)], allow: f64) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [96.0, 48.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 adjustment in [false, true] {
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.4, 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 as f64 <= allow * d.total as f64,
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1e-3 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
fn case_allow(id: &str, vals: &[(&str, Value)], allow: f64) {
direct(id, vals, allow);
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(96, 48);
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), allow);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), allow);
}
}
fn case(id: &str, vals: &[(&str, Value)]) {
case_allow(id, vals, 0.0);
}
/// `vals` in mono and in both stereo layouts.
fn layouts(id: &str, vals: &[(&str, Value)]) {
for l in 0..3 {
case(id, &[vals, &[("frameLayout", e(l))]].concat());
}
}
#[test]
fn blur_sharpen_glow() {
layouts("ec.vr.blur", &[("blurriness", n(12.0))]);
case("ec.vr.blur", &[("blurriness", n(60.0))]);
layouts("ec.vr.sharpen", &[("sharpenAmount", n(60.0))]);
layouts("ec.vr.glow", &[("luminanceThreshold", n(40.0)), ("glowRadius", n(10.0))]);
case("ec.vr.glow", &[("useTintColor", on()), ("glowSaturation", n(150.0)), ("glowBrightness", n(250.0))]);
}
#[test]
fn denoise() {
layouts("ec.vr.denoise", &[("noiseLevel", n(40.0))]);
case("ec.vr.denoise", &[("noiseLevel", n(60.0)), ("detail", n(30.0))]);
case("ec.vr.denoise", &[("noiseType", e(1)), ("noiseLevel", n(50.0))]);
case("ec.vr.denoise", &[("noiseType", e(1)), ("noiseLevel", n(80.0)), ("frameLayout", e(1))]);
}
#[test]
fn rotate_and_planes() {
layouts("ec.vr.rotatesphere", &[("tilt", n(20.0)), ("pan", n(35.0)), ("roll", n(-10.0))]);
case("ec.vr.rotatesphere", &[("tilt", n(-40.0)), ("pan", n(170.0)), ("invertRotation", on())]);
layouts("ec.vr.spheretoplane", &[("horizontalFov", n(100.0)), ("tilt", n(10.0)), ("pan", n(-30.0))]);
case("ec.vr.planetosphere", &[("scale", n(80.0)), ("pan", n(20.0)), ("tilt", n(-15.0))]);
case("ec.vr.planetosphere", &[("scale", n(120.0)), ("feather", n(30.0)), ("roll", n(25.0))]);
}
#[test]
fn converter() {
for from in 0..9 {
for to in [0u32, 1, 7] {
if from == to {
continue;
}
case_allow(
"ec.vr.converter",
&[("sourceProjection", e(from)), ("targetProjection", e(to)), ("sourceHorizontalFov", n(200.0)), ("pan", n(15.0))],
0.005,
);
}
}
for to in [2u32, 3, 4, 5, 6, 8] {
case_allow("ec.vr.converter", &[("targetProjection", e(to)), ("targetHorizontalFov", n(120.0)), ("tilt", n(12.0))], 0.005);
}
case_allow("ec.vr.converter", &[("targetProjection", e(1)), ("frameLayout", e(1))], 0.005);
case_allow("ec.vr.converter", &[("targetProjection", e(6)), ("frameLayout", e(2)), ("roll", n(30.0))], 0.005);
}
#[test]
fn generated() {
layouts("ec.vr.chromaticaberrations", &[("aberrationRed", n(40.0)), ("aberrationBlue", n(-30.0))]);
case("ec.vr.chromaticaberrations", &[("centerTilt", n(30.0)), ("centerPan", n(-60.0)), ("falloff", n(10.0)), ("falloffInvert", on())]);
layouts("ec.vr.colorgradients", &[]);
case("ec.vr.colorgradients", &[("blend", n(5.0)), ("opacity", n(60.0)), ("blendingMode", e(5)), ("enablePoint5", on())]);
for mode in 1..5 {
case("ec.vr.colorgradients", &[("blendingMode", e(mode)), ("color2", c(0.2, 0.9, 0.4))]);
}
layouts("ec.vr.fractalnoise", &[]);
for kind in 1..4 {
case("ec.vr.fractalnoise", &[("fractalType", e(kind)), ("complexity", n(4.5)), ("evolution", n(70.0)), ("transform/pan", n(30.0))]);
}
case("ec.vr.fractalnoise", &[("blendingMode", e(4)), ("opacity", n(70.0)), ("invert", on()), ("transform/scale", n(250.0))]);
layouts("ec.vr.digitalglitch", &[]);
case(
"ec.vr.digitalglitch",
&[
("distortionEvolution", n(100.0)),
("geometric/verticalDisplacement", n(80.0)),
("target/radius", n(60.0)),
("target/feather", n(30.0)),
("target/pan", n(40.0)),
("scanlineSpacing", n(3.0)),
],
);
}
+196
View File
@@ -0,0 +1,196 @@
//! GPU warp family effects (Warp, Bezier Warp, CC Bend It, CC Page Turn, Smear, Reshape, Color
//! Emboss, Cartoon) vs the CPU effects (the oracle): direct on a buffer at full and half
//! resolution, as adjustment, and composited at 8 and 32 bpc.
use std::io::Write;
use effectcraft_effects::MaskShape;
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, MaskMode};
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, diff, effect_case, gpu, n, on_llvmpipe, opts, pattern};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn warp_styles() {
for style in 0..15 {
effect_case("ec.distort.warp", &[("warpStyle", e(style)), ("bend", n(45.0))]);
}
effect_case("ec.distort.warp", &[("warpStyle", e(8)), ("warpAxis", e(1)), ("bend", n(-60.0)), ("horizontalDistortion", n(20.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(4)), ("bend", n(80.0)), ("verticalDistortion", n(-35.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(11)), ("bend", n(-50.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(14)), ("bend", n(-35.0))]);
// Strong Fisheye / Twist bends and either with a distortion: the CPU's f64 inverse map.
for (style, bend) in [(11, 90.0), (11, -100.0), (14, 75.0), (14, -100.0)] {
effect_case("ec.distort.warp", &[("warpStyle", e(style)), ("bend", n(bend))]);
}
effect_case("ec.distort.warp", &[("warpStyle", e(14)), ("verticalDistortion", n(-35.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(11)), ("warpAxis", e(1)), ("bend", n(60.0)), ("horizontalDistortion", n(30.0))]);
effect_case("ec.distort.warp", &[("warpStyle", e(3)), ("bend", n(0.0)), ("horizontalDistortion", n(-40.0)), ("verticalDistortion", n(25.0))]);
}
#[test]
fn bezier_warp() {
effect_case("ec.distort.bezierwarp", &[]);
effect_case(
"ec.distort.bezierwarp",
&[("topLeftVertex", pt(8.0, -4.0)), ("topLeftTangent", pt(30.0, 12.0)), ("rightBottomTangent", pt(80.0, 30.0)), ("leftBottomVertex", pt(-6.0, 50.0))],
);
effect_case("ec.distort.bezierwarp", &[("topRightTangent", pt(40.0, -20.0)), ("bottomLeftTangent", pt(20.0, 60.0)), ("quality", n(3.0))]);
}
#[test]
fn bend_it_and_page_turn() {
effect_case("ec.distort.ccbendit", &[("bend", n(45.0))]);
effect_case("ec.distort.ccbendit", &[("bend", n(-120.0)), ("start", pt(10.0, 30.0)), ("end", pt(55.0, 8.0)), ("renderPrestart", e(1))]);
// A slight bend: a large arc radius.
effect_case("ec.distort.ccbendit", &[("bend", n(0.05)), ("start", pt(5.0, 22.0)), ("end", pt(60.0, 22.0))]);
effect_case("ec.distort.ccpageturn", &[]);
effect_case("ec.distort.ccpageturn", &[("foldRadius", n(4.0)), ("foldDirection", n(30.0)), ("foldPosition", pt(40.0, 20.0))]);
effect_case(
"ec.distort.ccpageturn",
&[("render", e(2)), ("backPageOpacity", n(40.0)), ("paperColor", c(0.9, 0.85, 0.7)), ("lightDirection", n(120.0)), ("foldPosition", pt(30.0, 25.0))],
);
effect_case("ec.distort.ccpageturn", &[("render", e(1)), ("foldPosition", pt(50.0, 10.0))]);
}
#[test]
fn color_emboss_and_cartoon() {
effect_case("ec.stylize.coloremboss", &[]);
effect_case("ec.stylize.coloremboss", &[("direction", n(30.0)), ("relief", n(3.5)), ("contrast", n(250.0)), ("blend", n(30.0))]);
effect_case("ec.stylize.cartoon", &[]);
effect_case("ec.stylize.cartoon", &[("render", e(0)), ("detailRadius", n(4.0)), ("fill/shadingSteps", n(5.0)), ("fill/shadingSmoothness", n(0.0))]);
effect_case("ec.stylize.cartoon", &[("render", e(1)), ("edge/edgeWidth", n(0.6)), ("advanced/edgeBlackLevel", n(40.0)), ("advanced/edgeContrast", n(0.8))]);
effect_case("ec.stylize.cartoon", &[("edge/edgeWidth", n(3.3)), ("advanced/edgeEnhancement", n(60.0)), ("edge/edgeSoftness", n(20.0))]);
effect_case("ec.stylize.cartoon", &[("advanced/edgeEnhancement", n(-50.0)), ("detailRadius", n(0.0))]);
}
/// Mask outlines in layer pixels (70 × 44 layers): a source blob, a destination blob and a
/// boundary around both.
fn outlines() -> Vec<MaskShape> {
let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| -> Vec<[f64; 2]> {
(0..40).map(|i| i as f64 / 40.0 * std::f64::consts::TAU).map(|a| [cx + rx * a.cos(), cy + ry * a.sin()]).collect()
};
let shape = |name: &str, points| MaskShape { name: name.into(), points, closed: true, inverted: false };
vec![shape("Mask 1", ellipse(25.0, 20.0, 9.0, 7.0)), shape("Mask 2", ellipse(42.0, 24.0, 12.0, 6.0)), shape("Mask 3", ellipse(34.0, 22.0, 30.0, 19.0))]
}
/// The effect alone on a buffer with masks (`effect_direct` with the layer's masks).
fn direct_with_masks(id: &str, vals: &[(&str, Value)]) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
let masks = outlines();
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 adjustment in [false, true] {
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: effectcraft_effects::EffectEnv { masks: &masks, ..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");
assert!(cpu.img.data != buf.img.data, "{id} {vals:?}: the case changes the picture");
let d = diff(&cpu.img, &out.img, 1.0 / 255.0);
// f32 point-in-polygon tests may flip a pixel exactly on an outline.
assert!(
d.over * 1000 <= d.total,
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1/255 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
/// Composited at 8 and 32 bpc, with the masks on the layer (mode None: they only feed the effect).
fn composite_with_masks(id: &str, vals: &[(&str, Value)]) {
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);
let mut next = s.p.next_id;
for m in outlines() {
let path = ShapePath {
vertices: m.points.clone(),
in_tangents: vec![[0.0; 2]; m.points.len()],
out_tangents: vec![[0.0; 2]; m.points.len()],
closed: true,
..Default::default()
};
let g = build::mask(&mut Ids(&mut next), &m.name, path, MaskMode::None, [255, 255, 0]);
l.props.sub_mut("masks").unwrap().children.push(g.into());
}
s.p.next_id = next;
s.effect(&mut l, id, vals);
s.push(l);
check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.003);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.003);
}
}
#[test]
fn smear_and_reshape() {
let smear: &[&[(&str, Value)]] = &[
&[("sourceMask", e(1)), ("boundaryMask", e(3)), ("maskOffset", pt(10.0, 3.0))],
&[
("sourceMask", e(1)),
("boundaryMask", e(3)),
("maskOffset", pt(-4.0, 6.0)),
("maskRotation", n(30.0)),
("maskScale", n(130.0)),
("elasticity", e(6)),
("percent", n(70.0)),
],
];
for vals in smear {
direct_with_masks("ec.distort.smear", vals);
composite_with_masks("ec.distort.smear", vals);
}
let reshape: &[&[(&str, Value)]] = &[
&[("sourceMask", e(1)), ("destinationMask", e(2))],
&[("sourceMask", e(1)), ("destinationMask", e(2)), ("boundaryMask", e(3)), ("interpolation", e(2)), ("elasticity", e(0))],
&[
("sourceMask", e(1)),
("destinationMask", e(2)),
("boundaryMask", e(3)),
("correspondencePoints", Value::Str("0,0.25 0.5,0.8".into())),
("percent", n(60.0)),
],
];
for vals in reshape {
if on_llvmpipe() && vals.iter().any(|(k, _)| *k == "boundaryMask") {
// Straight to stderr: libtest captures `eprintln!` of passing tests, and the skip
// should show in the log.
let _ = writeln!(std::io::stderr(), "tests_fx_warp::smear_and_reshape: skipping ec.distort.reshape {vals:?} on llvmpipe (crashes lavapipe, #2238)");
continue;
}
direct_with_masks("ec.distort.reshape", vals);
composite_with_masks("ec.distort.reshape", vals);
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Native constructor containment. Tiny original shaders; no frames or large allocations.
//! Set EFFECTCRAFT_REQUIRE_GPU=1 for acceptance: unavailable adapters/devices then fail
//! instead of taking the explicitly logged headless-CI skip path.
#![cfg(not(target_arch = "wasm32"))]
#![allow(clippy::expect_used)] // Test helpers require successful setup to exercise rejection.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(target_os = "windows")]
use crate::context::GpuContext;
use crate::context::init_resource;
fn brief(message: impl std::fmt::Display) -> String {
message.to_string().chars().take(1024).collect()
}
fn native_device(desc: wgpu::InstanceDescriptor, required: bool) -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
crate::tests::hold_gpu_lock();
let required = required || std::env::var("EFFECTCRAFT_REQUIRE_GPU").is_ok_and(|value| value == "1");
let instance = wgpu::Instance::new(desc);
let adapter = match pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
) {
Ok(adapter) => adapter,
Err(error) => {
assert!(!required, "required initialization-test adapter unavailable: {}", brief(error));
eprintln!("initialization tests: no adapter, explicitly skipping");
return None;
}
};
let info = adapter.get_info();
eprintln!("initialization tests: adapter {} ({:?})", brief(&info.name), info.backend);
let limits = adapter.limits();
let desc = wgpu::DeviceDescriptor {
label: Some("initialization containment test"),
required_limits: wgpu::Limits {
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
max_buffer_size: limits.max_buffer_size,
..wgpu::Limits::default()
},
..Default::default()
};
match pollster::block_on(adapter.request_device(&desc)) {
Ok((device, queue)) => Some((adapter, device, queue)),
Err(error) => {
assert!(!required, "required initialization-test device unavailable: {}", brief(error));
eprintln!("initialization tests: no device, explicitly skipping");
None
}
}
}
fn count_host_errors(device: &wgpu::Device) -> Arc<AtomicUsize> {
let calls = Arc::new(AtomicUsize::new(0));
let count = calls.clone();
device.on_uncaptured_error(Arc::new(move |_| {
count.fetch_add(1, Ordering::SeqCst);
}));
calls
}
fn invalid_module(device: &wgpu::Device) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("original deliberately invalid initialization-test module"),
source: wgpu::ShaderSource::Wgsl("this is deliberately not WGSL".into()),
})
}
fn valid_module(device: &wgpu::Device) -> wgpu::ShaderModule {
init_resource(device, "valid tiny module", || {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("original empty initialization-test kernel"),
source: wgpu::ShaderSource::Wgsl("@compute @workgroup_size(1) fn main() {}".into()),
})
})
.expect("a valid tiny module still compiles after rejection")
}
fn pipeline(device: &wgpu::Device, module: &wgpu::ShaderModule, entry: &str) -> wgpu::ComputePipeline {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("initialization-test pipeline"),
layout: None,
module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
}
fn valid_pipeline(device: &wgpu::Device) {
let module = valid_module(device);
init_resource(device, "valid tiny pipeline", || pipeline(device, &module, "main")).expect("a valid pipeline still compiles after rejection");
}
#[test]
fn scoped_module_and_pipeline_errors_preserve_outer_scope_and_host_handler() {
let Some((_, device, _queue)) = native_device(wgpu::InstanceDescriptor::new_without_display_handle().with_env(), false) else { return };
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let error = init_resource(&device, "invalid tiny module", || invalid_module(&device)).expect_err("invalid module must return Err");
assert!(error.contains("GPU initialization (invalid tiny module)"), "{}", brief(error));
let module = valid_module(&device);
let error = init_resource(&device, "missing tiny entry", || pipeline(&device, &module, "missing_entry")).expect_err("missing entry point must return Err");
assert!(error.contains("GPU initialization (missing tiny entry)"), "{}", brief(error));
assert_eq!(host.load(Ordering::SeqCst), 0, "contained errors must not reach the host");
valid_pipeline(&device);
// This belongs to our pre-existing caller scope. Its successful pop also proves that
// none of the three nested helper scopes remain on the native thread-local stack.
let _invalid = invalid_module(&device);
assert!(pollster::block_on(outer.pop()).is_some(), "the caller's own scope survives");
assert_eq!(host.load(Ordering::SeqCst), 0);
let _invalid = invalid_module(&device);
assert_eq!(host.load(Ordering::SeqCst), 1, "the original host handler still receives unscoped errors");
}
#[test]
fn initialization_unwind_drains_all_scopes_before_the_next_operation() {
let Some((_, device, _queue)) = native_device(wgpu::InstanceDescriptor::new_without_display_handle().with_env(), false) else { return };
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
init_resource::<()>(&device, "synthetic native unwind", || panic!("original synthetic initialization failure"))
}));
let error = result.expect("the initialization unwind is contained").expect_err("unwind becomes an error");
assert!(error.contains("synthetic native unwind") && error.contains("original synthetic initialization failure"), "{}", brief(error));
valid_pipeline(&device);
let _invalid = invalid_module(&device);
assert!(pollster::block_on(outer.pop()).is_some(), "all helper scopes must be drained after unwind");
assert_eq!(host.load(Ordering::SeqCst), 0);
let _invalid = invalid_module(&device);
assert_eq!(host.load(Ordering::SeqCst), 1, "unwind must neither replace the host handler nor leak a scope");
}
/// Acceptance for the captured RTX 5090 / FXC failure. Future compiler/shader fixes can
/// legitimately make FXC succeed; this diagnostic is deliberately outside portable CI.
/// On OpenGL the compositor declines at once instead of translating its kernels for tens of
/// seconds and then failing (#243). Skips where there is no OpenGL adapter.
#[test]
fn opengl_adapters_get_cpu_compositing_at_once() {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::GL;
let Some((adapter, device, queue)) = native_device(desc, false) else { return };
let t = std::time::Instant::now();
let error = crate::Gpu::new(&adapter, device, queue).err().expect("no GPU compositor on OpenGL");
assert!(error.contains("(Gl)"), "{error}");
assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed());
}
#[test]
#[cfg(target_os = "windows")]
#[ignore = "requires the reproduced Windows DX12/FXC pointwise compiler failure; no adapter skips"]
fn reproduced_fxc_pointwise_failure_returns_error_without_escaping_or_notifying_host() {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::DX12;
desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
let (adapter, device, queue) = native_device(desc, true).expect("required DX12 device");
assert_eq!(adapter.get_info().backend, wgpu::Backend::Dx12);
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| GpuContext::new(&adapter, device.clone(), queue)));
let error = match result.expect("FXC compiler failure must not escape the constructor") {
Err(error) => error,
Ok(_) => panic!("the platform-specific FXC failure no longer reproduces; reassess this diagnostic"),
};
assert!(error.contains("GPU initialization (pointwise)") && error.contains("FXC D3DCompile"), "{}", brief(error));
assert!(pollster::block_on(outer.pop()).is_none(), "constructor must drain its own scopes and leave the caller's scope clean");
assert_eq!(host.load(Ordering::SeqCst), 0);
valid_pipeline(&device);
let _invalid = invalid_module(&device);
assert_eq!(host.load(Ordering::SeqCst), 1);
}
+155
View File
@@ -0,0 +1,155 @@
//! GPU particles vs the CPU simulation (the oracle): CC Particle World, CC Particle Systems II
//! and Particle Playground's cannon at fixed seeds must give the same live particles (same
//! ids) at positions within tolerance, from scratch and when advancing / seeking from GPU
//! checkpoints.
use effectcraft_effects::psim::{ParticleSim, SimParticle};
use effectcraft_effects::{EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
use effectcraft_keyframe::Value;
use crate::Gpu;
use crate::tests::{gpu, n};
pub(crate) struct Host(pub(crate) &'static Gpu);
impl EffectHost for Host {
fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
fn particles(&self) -> Option<&dyn ParticleSim> {
Some(self.0)
}
}
pub(crate) fn params(id: &str, vals: &[(&str, Value)]) -> Params {
let spec = effectcraft_effects::find(id).unwrap();
let size = [320.0, 240.0];
let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
assert!(p.values.contains_key(*k), "{id}: no {k}");
p.values.insert(k.to_string(), v.clone());
}
p
}
/// (CPU, GPU) particles of effect `id` at layer time `t`.
fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec<SimParticle>, Vec<SimParticle>) {
let ctx = |env| EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
let run =
|c: &EffectCtx| if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) };
let cpu = run(&ctx(EffectEnv::default())).expect("cpu state");
let gpu = run(&ctx(EffectEnv { host: Some(host), ..Default::default() })).expect("gpu state");
(cpu, gpu)
}
/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol`
/// (world units) for 99 % of them and on average within `tol / 10`.
pub(crate) fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) {
assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len());
let gi: std::collections::HashMap<u32, &SimParticle> = gpu.iter().map(|q| (q.id, q)).collect();
let mut missing = 0;
let mut errs = vec![];
for q in cpu {
match gi.get(&q.id) {
Some(g) => errs.push((0..3).map(|k| (q.p[k] - g.p[k]).abs()).fold(0.0f32, f32::max)),
None => missing += 1,
}
}
let extra = gpu.len() + missing - cpu.len();
assert!(missing + extra <= id_slack, "{label}: {missing} CPU particles missing on the GPU, {extra} extra (of {})", cpu.len());
errs.sort_by(f32::total_cmp);
let mean = errs.iter().sum::<f32>() / errs.len() as f32;
let p99 = errs[(errs.len() * 99 / 100).min(errs.len() - 1)];
eprintln!("{label}: {} particles, mean err {mean:.2e}, p99 {p99:.2e}, max {:.2e}", cpu.len(), errs.last().unwrap());
assert!(p99 <= tol && mean <= tol / 10.0, "{label}: positions differ (mean {mean}, p99 {p99})");
}
#[test]
fn particle_world_matches_the_cpu() {
let Some(g) = gpu() else { return };
let host = Host(g);
for anim in [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
let p = params(
"ec.sim.ccparticleworld",
&[
("physics/animation", Value::Enum(anim)),
("birthRate", n(2.0)),
("longevity", n(1.2)),
("physics/extra", n(0.6)),
("physics/resistance", n(0.5)),
("extras/randomSeed", n(7.0)),
],
);
let (cpu, gpu) = states("ec.sim.ccparticleworld", &p, 1.5, &host);
// Fractal / fire animations sample value noise along the path: rounding differences can
// grow, so they get a looser bound.
let tol = if matches!(anim, 6 | 8 | 9) { 1e-3 } else { 1e-4 };
agree(&format!("particle world anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1);
}
}
#[test]
fn particle_systems_ii_matches_the_cpu() {
let Some(g) = gpu() else { return };
let host = Host(g);
for anim in [0u32, 1, 2, 4, 5, 8] {
let p = params(
"ec.sim.ccparticlesystems2",
&[("physics/animation", Value::Enum(anim)), ("birthRate", n(3.0)), ("longevity", n(1.5)), ("physics/gravity", n(0.8)), ("randomSeed", n(3.0))],
);
let (cpu, gpu) = states("ec.sim.ccparticlesystems2", &p, 2.0, &host);
let tol = if anim == 8 { 1e-3 } else { 1e-4 };
agree(&format!("particle systems ii anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1);
}
}
#[test]
fn playground_cannon_matches_the_cpu_and_checkpoints_reuse() {
let Some(g) = gpu() else { return };
let host = Host(g);
for (radius, spread) in [(4.0, 0.0), (-10.0, 50.0)] {
let p = params(
"ec.sim.particleplayground",
&[
("grid/particlesAcross", n(0.0)),
("cannon/barrelRadius", n(radius)),
("cannon/particlesPerSecond", n(90.0)),
("cannon/directionRandomSpread", n(30.0)),
("cannon/velocityRandomSpread", n(40.0)),
("gravity/gravityForceRandomSpread", n(spread)),
],
);
// Advance through checkpoints (0.5 → 1.0 → 2.0), seek back (1.5), compare each.
for t in [0.5, 1.0, 2.0, 1.5] {
let (cpu, gpu) = states("ec.sim.particleplayground", &p, t, &host);
// Positions are layer pixels here.
agree(&format!("cannon r {radius} spread {spread} t {t}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1);
}
}
}
/// Through the renderer: the GPU backend's effect host hands the simulation to the GPU, and
/// the composited frames agree with the CPU render.
#[test]
fn particle_effects_render_like_the_cpu() {
use effectcraft_project::BitDepth;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, opts};
for (id, vals) in [
("ec.sim.ccparticleworld", vec![("birthRate", n(1.0)), ("physics/animation", Value::Enum(4))]),
("ec.sim.ccparticlesystems2", vec![("birthRate", n(2.0))]),
("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(60.0))]),
] {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.solid([0.1, 0.1, 0.15], 97, 61);
s.effect(&mut l, id, &vals);
s.push(l);
check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(1.0)), 0.002);
}
}
}
+290
View File
@@ -0,0 +1,290 @@
//! Classic 3D on the GPU vs the CPU compositor: intersecting planes, blend modes, lights,
//! ray-cast shadows, depth of field, motion blur, track mattes, collapsed precomps and
//! adjustment layers splitting a run. Same tolerances as `tests.rs`; pixels on the exact
//! intersection line of two planes may sort differently (f32 vs f64 depths), so intersecting
//! scenes allow 0.5 % of pixels over.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKind, MatteKind, TrackMatte};
use effectcraft_render::{Backend, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set};
const W: u32 = 160;
const H: u32 = 100;
fn scene(depth: BitDepth) -> Scene {
let mut s = Scene::new(depth);
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
*s.p.comp_mut(s.cid).unwrap() = comp.clone();
s.comp = comp;
s
}
fn three_d(mut l: Layer, pos: [f64; 3], rot_y: f64) -> Layer {
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
set(&mut l, "transform/rotationY", Value::Scalar(rot_y));
l
}
fn camera(s: &mut Scene, pos: [f64; 3], dof: Option<(f64, f64)>) {
let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (W, H), None);
set(&mut cam, "cameraOptions/zoom", Value::Scalar(W as f64 * 50.0 / 36.0));
set(&mut cam, "transform/position", Value::Vec3(pos));
set(&mut cam, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0]));
if let Some((focus, aperture)) = dof {
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(aperture));
}
s.push(cam);
}
fn light(s: &mut Scene, kind: LightKind, pos: [f64; 3], vals: &[(&str, Value)]) {
let mut l = build::layer(&mut s.p, &s.comp, "Light", LayerSource::Light { kind }, (W, H), None);
if kind != LightKind::Ambient {
set(&mut l, "transform/position", Value::Vec3(pos));
}
if l.props.prop_mut("transform/poi").is_some() {
set(&mut l, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0]));
}
for (k, v) in vals {
set(&mut l, &format!("lightOptions/{k}"), v.clone());
}
s.push(l);
}
/// Background, a far plane, and two planes crossing each other (one in `mode`).
fn crossing(depth: BitDepth, mode: BlendMode) -> Scene {
let mut s = scene(depth);
let bg = s.solid([0.1, 0.12, 0.2], W, H);
s.push(bg);
let far = three_d(s.footage(150, 90), [80.0, 50.0, 120.0], 0.0);
s.push(far);
let a = three_d(s.footage(90, 70), [80.0, 50.0, 0.0], 35.0);
s.push(a);
let mut b = three_d(s.solid([0.9, 0.5, 0.2], 90, 70), [80.0, 52.0, 0.0], -35.0);
b.blend_mode = mode;
set(&mut b, "transform/opacity", Value::Scalar(80.0));
s.push(b);
s
}
#[test]
fn intersecting_planes_and_blend_modes() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Multiply, BlendMode::Add, BlendMode::Dissolve] {
let mut s = crossing(depth, mode);
check(&format!("crossing {mode:?} {depth:?} (default camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005);
camera(&mut s, [30.0, 20.0, -260.0], None);
check(&format!("crossing {mode:?} {depth:?} (camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
}
#[test]
fn lights_specular_transmission_and_shadows() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
// Every layer casts shadows; the crossing pair transmits light.
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
if l.switches.three_d {
set(l, "materialOptions/castsShadows", Value::Enum(1));
set(l, "materialOptions/specularIntensity", Value::Scalar(60.0));
set(l, "materialOptions/specularShininess", Value::Scalar(30.0));
set(l, "materialOptions/lightTransmission", Value::Scalar(40.0));
}
}
camera(&mut s, [-20.0, -10.0, -250.0], None);
light(&mut s, LightKind::Ambient, [0.0, 0.0, 0.0], &[("intensity", n(25.0))]);
light(
&mut s,
LightKind::Spot,
[20.0, -60.0, -150.0],
&[("castsShadows", Value::Bool(true)), ("shadowDarkness", n(80.0)), ("coneAngle", n(70.0)), ("coneFeather", n(40.0)), ("intensity", n(120.0))],
);
light(
&mut s,
LightKind::Point,
[150.0, 20.0, -80.0],
&[
("castsShadows", Value::Bool(true)),
("shadowDiffusion", n(6.0)),
("falloff", Value::Enum(1)),
("radius", n(120.0)),
("falloffDistance", n(200.0)),
],
);
light(&mut s, LightKind::Parallel, [-100.0, -100.0, -100.0], &[("castsShadows", Value::Bool(true)), ("color", Value::Color([0.5, 0.7, 1.0, 1.0]))]);
check(&format!("lights + shadows {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// Accepts Shadows: Only, Accepts Lights off, Casts Shadows: Only.
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
match l.name.as_str() {
"Pattern" => set(l, "materialOptions/acceptsShadows", Value::Enum(2)),
"Solid" if l.switches.three_d => set(l, "materialOptions/acceptsLights", Value::Bool(false)),
_ => {}
}
}
check(&format!("shadow-only materials {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
#[test]
fn depth_of_field_and_motion_blur() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
camera(&mut s, [80.0, 50.0, -220.0], Some((220.0, 30.0)));
check(&format!("dof {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// Animated plane with motion blur (sub-sampled geometry, equal weights).
let mut s = scene(depth);
let mut l = three_d(s.footage(60, 40), [40.0, 50.0, 0.0], 20.0);
l.switches.motion_blur = true;
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([40.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([140.0, 40.0, -60.0]))];
s.push(l);
s.p.comp_mut(s.cid).unwrap().enable_motion_blur = true;
light(&mut s, LightKind::Point, [80.0, 0.0, -100.0], &[]);
check(&format!("3d motion blur {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.5)), 0.005);
}
}
#[test]
fn mattes_preserve_transparency_precomps_and_adjustment_splits() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
// A 2D luma matte for the top plane, preserve transparency on the far plane.
let m = s.solid([0.7, 0.7, 0.7], 70, 50);
let mid = s.push(m);
let layers = &mut s.p.comp_mut(s.cid).unwrap().layers;
layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma });
if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") {
far.preserve_transparency = true;
}
// Move the matte below the bottom so the 3D run stays one run.
let ml = layers.remove(0);
layers.push(ml);
layers.last_mut().unwrap().switches.video = false;
check(&format!("matte + preserve {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// An adjustment layer in the middle of the run splits it.
let mut s = crossing(depth, BlendMode::Normal);
let mut adj = s.solid([1.0, 1.0, 1.0], W, H);
adj.switches.adjustment = true;
adj.switches.three_d = true;
s.effect(&mut adj, "ec.channel.invert", &[]);
s.p.comp_mut(s.cid).unwrap().layers.insert(1, adj);
check(&format!("adjustment split {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// A collapsed 3D precomp: its layers join the parent's 3D space.
let mut s = crossing(depth, BlendMode::Normal);
let inner = Comp::new(80, 60, 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 mut a = s.footage(50, 40);
a.switches.three_d = true;
set(&mut a, "transform/rotationX", Value::Scalar(40.0));
let b = s.solid([0.2, 0.8, 0.4], 30, 20);
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a];
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None);
pre.switches.collapse = true;
pre.switches.three_d = true;
set(&mut pre, "transform/position", Value::Vec3([70.0, 45.0, -10.0]));
s.push(pre);
check(&format!("collapsed 3d precomp {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
#[test]
fn the_gpu_draws_classic_3d_runs_itself() {
let Some(g) = gpu() else { return };
let s = crossing(BitDepth::Bpc32, BlendMode::Normal);
let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap();
let vis = r.visible_layers(&ctx);
let run: Vec<_> = vis.iter().copied().filter(|l| l.is_3d()).collect();
let prep = r.prepare_3d_run(&ctx, &run, (W, H)).expect("a classic run is prepared for the GPU");
assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3);
let mut e = crate::context::Enc::new(g.context());
let canvas = e.image(W, H);
let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run");
let img = e.download(&out).unwrap();
let mut cpu = effectcraft_raster::Image::new(W, H);
r.draw_3d_run(&ctx, &run, &mut cpu);
let covered = cpu.data.iter().filter(|p| p[3] > 0.5).count();
assert!(covered > (W * H / 3) as usize, "the planes cover the frame ({covered} px)");
let d = crate::tests::diff(&cpu, &img, 1e-3);
assert!(d.over * 200 <= d.total, "{d:?}");
}
/// Classic 3D bokeh depth of field runs on the GPU: iris shapes, highlights, fringe, Fast
/// Rectangle and a tilted plane (progressive blur) match the CPU.
#[test]
fn bokeh_depth_of_field_runs_on_the_gpu() {
let cases: [(&str, &[(&str, Value)]); 4] = [
("fast rectangle", &[]),
("hexagon + highlights", &[("irisShape", Value::Enum(4)), ("irisRotation", n(15.0)), ("highlightGain", n(40.0)), ("highlightThreshold", n(150.0))]),
("round + fringe", &[("irisShape", Value::Enum(6)), ("irisRoundness", n(60.0)), ("irisDiffractionFringe", n(80.0))]),
("wide triangle", &[("irisShape", Value::Enum(1)), ("irisAspectRatio", n(2.0))]),
];
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (label, vals) in cases {
// The crossing planes are tilted (±35°): every plane blurs progressively.
let mut s = crossing(depth, BlendMode::Normal);
camera(&mut s, [80.0, 50.0, -220.0], Some((180.0, 40.0)));
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
if l.name == "Camera" {
for (k, v) in vals {
set(l, &format!("cameraOptions/{k}"), v.clone());
}
}
}
check(&format!("bokeh {label} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// The blur is the GPU's: the prepared planes carry it unapplied.
if depth == BitDepth::Bpc32
&& let Some(g) = gpu()
{
let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap();
let run: Vec<_> = r.visible_layers(&ctx).into_iter().filter(|l| l.is_3d()).collect();
let prep = r.prepare_3d_run(&ctx, &run, (W, H)).unwrap();
assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU");
let mut e = crate::context::Enc::new(g.context());
let canvas = e.image(W, H);
assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU");
}
}
}
}
/// A 2D layer whose track matte is a 3D layer: the matte is drawn through the camera on the
/// GPU walk as on the CPU.
#[test]
fn two_d_layer_with_a_three_d_track_matte() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for kind in [MatteKind::Alpha, MatteKind::Luma] {
let mut s = scene(depth);
let bg = s.solid([0.1, 0.12, 0.2], W, H);
s.push(bg);
let fill = s.footage(W, H);
let fid = s.push(fill);
// The matte: a solid turned in 3D, seen through a camera.
let m = three_d(s.solid([0.9, 0.9, 0.9], 70, 50), [80.0, 50.0, 30.0], 40.0);
let mid = s.push(m);
camera(&mut s, [40.0, 30.0, -200.0], None);
let layers = &mut s.p.comp_mut(s.cid).unwrap().layers;
let fi = layers.iter().position(|l| l.id == fid).unwrap();
layers[fi].track_matte = Some(TrackMatte { layer: mid, kind });
if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) {
ml.switches.video = false;
}
check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// The same with the Advanced 3D renderer (its CPU path draws 3D mattes through the
// camera with composite_iso_with).
s.p.comp_mut(s.cid).unwrap().renderer = effectcraft_project::Renderer::Advanced3D;
check(&format!("3d matte {kind:?} {depth:?} (Advanced 3D)"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
}
+233
View File
@@ -0,0 +1,233 @@
//! WGSL rules naga accepts but stricter front-ends (Tint, i.e. the browser's
//! WebGPU, which receives the WGSL source verbatim on wasm) reject.
//!
//! WGSL gives the bitwise operators `&`, `|` and `^` no precedence against any other
//! binary operator: `a * b ^ c` must be written `(a * b) ^ c`, and `a & b == c` as
//! `(a & b) == c`. Only a chain of the same bitwise operator may go unparenthesised.
use std::fs;
use std::path::Path;
#[derive(Clone, Copy, PartialEq)]
enum Tok<'a> {
Operand,
Open,
Close,
Op(&'a str),
/// A unary prefix (`!`, `~`): the operator after it cannot be binary.
Prefix,
/// Ends an expression: `,` `;` `{` `}` `:` `=` compound assignment, `->`, `@`.
Sep,
}
const BITWISE: [&str; 3] = ["&", "|", "^"];
const OPS: [&str; 18] = ["&&", "||", "==", "!=", "<=", ">=", "<<", ">>", "<", ">", "+", "-", "*", "/", "%", "&", "|", "^"];
/// Drop comments and template argument lists (`vec3<f32>`, `array<vec4<u32>>`), whose
/// angle brackets are not relational operators.
fn strip(src: &str) -> String {
let mut out = drop_comments(src);
loop {
let (next, changed) = drop_template_lists_once(&out);
out = next;
if !changed {
return out;
}
}
}
fn drop_comments(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let b = src.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i..].starts_with(b"//") {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
} else if b[i..].starts_with(b"/*") {
i += 2;
while i + 1 < b.len() && !b[i..].starts_with(b"*/") {
out.push(if b[i] == b'\n' { '\n' } else { ' ' });
i += 1;
}
i += 2;
} else {
out.push(b[i] as char);
i += 1;
}
}
out
}
/// One pass over `out` dropping every template argument list. Returns whether one was dropped.
fn drop_template_lists_once(out: &str) -> (String, bool) {
let mut changed = false;
let mut next = String::with_capacity(out.len());
let c: Vec<char> = out.chars().collect();
let mut i = 0;
while i < c.len() {
if let Some(end) = template_list_end(&c, i) {
i = end;
changed = true;
continue;
}
next.push(c[i]);
i += 1;
}
(next, changed)
}
/// If `c[i]` opens a template argument list after an identifier, the index just past its `>`.
fn template_list_end(c: &[char], i: usize) -> Option<usize> {
let prev = i.checked_sub(1).and_then(|p| c.get(p))?;
if c.get(i) != Some(&'<') || !(prev.is_alphanumeric() || *prev == '_') {
return None;
}
let mut j = i + 1;
while let Some(&ch) = c.get(j) {
if !(ch.is_alphanumeric() || ch == '_' || ch == ',' || ch == ' ') {
break;
}
j += 1;
}
(c.get(j) == Some(&'>')).then_some(j + 1)
}
/// Scans the token starting at `*at` (not whitespace) and advances `*at` past it.
fn next_token<'a>(s: &'a str, at: &mut usize) -> Tok<'a> {
let b = s.as_bytes();
let Some(&ch) = b.get(*at) else {
return Tok::Sep;
};
let mut i = *at;
let tok = if ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'.' {
// Identifier, member access or number (`1e-6`, `0x9E3779B9u`).
let start = i;
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') {
if (b[i] == b'e' || b[i] == b'E')
&& b[start].is_ascii_digit()
&& !s[start..i].starts_with("0x")
&& i + 1 < b.len()
&& (b[i + 1] == b'-' || b[i + 1] == b'+')
{
i += 1;
}
i += 1;
}
Tok::Operand
} else if ch == b'(' || ch == b'[' {
i += 1;
Tok::Open
} else if ch == b')' || ch == b']' {
i += 1;
Tok::Close
} else if b[i..].starts_with(b"->") || b[i..].starts_with(b"++") || b[i..].starts_with(b"--") {
i += 2;
Tok::Sep
} else if matches!(ch, b',' | b';' | b'{' | b'}' | b':' | b'@') {
i += 1;
Tok::Sep
} else if let Some(op) = OPS.iter().find(|op| b[i..].starts_with(op.as_bytes())) {
i += op.len();
if b.get(i) == Some(&b'=') && !matches!(*op, "==" | "!=" | "<=" | ">=") {
i += 1;
Tok::Sep // compound assignment
} else {
Tok::Op(op)
}
} else if ch == b'=' {
i += 1;
Tok::Sep
} else {
i += 1;
Tok::Prefix
};
*at = i;
tok
}
/// Records a binary operator at the current parenthesis level, reporting it if it mixes with
/// one already seen there.
fn note_operator<'a>(level: &mut Vec<&'a str>, op: &'a str, line: usize, out: &mut Vec<(usize, String)>) {
let mixes = |a: &str, b: &str| a != b && (BITWISE.contains(&a) || BITWISE.contains(&b));
if let Some(other) = level.iter().find(|o| mixes(o, op)) {
out.push((line, format!("mixing '{other}' and '{op}' requires parentheses")));
}
level.push(op);
}
/// `(line, message)` for every bitwise operator mixed with another binary operator at
/// the same parenthesis level.
fn violations(src: &str) -> Vec<(usize, String)> {
let s = strip(src);
let b = s.as_bytes();
let mut out = vec![];
// Binary operators seen at each open parenthesis level.
let mut levels: Vec<Vec<&str>> = vec![vec![]];
let mut prev = Tok::Sep;
let (mut i, mut line) = (0, 1);
while i < b.len() {
let ch = b[i];
if ch == b'\n' {
line += 1;
i += 1;
continue;
}
if ch.is_ascii_whitespace() {
i += 1;
continue;
}
let tok = next_token(&s, &mut i);
match tok {
Tok::Open => levels.push(vec![]),
Tok::Close => {
if levels.len() > 1 {
levels.pop();
}
}
Tok::Sep => {
if let Some(l) = levels.last_mut() {
l.clear();
}
}
Tok::Op(op) if matches!(prev, Tok::Operand | Tok::Close) => {
let level = levels.last_mut().expect("level");
note_operator(level, op, line, &mut out);
}
_ => {}
}
prev = tok;
}
out
}
#[test]
fn checker_flags_unparenthesised_bitwise_mixes() {
assert_eq!(violations("let h = u32(x) * 3u ^ u32(y) * 5u;").len(), 2);
assert_eq!(violations("let a = b & c == d;").len(), 1);
assert!(violations("let h = (u32(x) * 3u) ^ (u32(y) * 5u);").is_empty());
assert!(violations("let h = a ^ b ^ c; let m = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;").is_empty());
assert!(violations("let v: vec3<f32> = vec3<f32>(1e-6, -x, a < b); if (a < b && c > d) { h ^= h >> 16u; }").is_empty());
assert!(violations("var p = &x; let y = -a & b;").is_empty());
}
#[test]
fn shaders_parenthesise_bitwise_operators() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shaders");
let mut bad = vec![];
let mut files = 0;
for entry in fs::read_dir(&dir).expect("shader dir") {
let path = entry.expect("entry").path();
if path.extension().is_some_and(|e| e == "wgsl") {
files += 1;
let src = fs::read_to_string(&path).expect("shader source");
for (line, msg) in violations(&src) {
bad.push(format!("{}:{line}: {msg}", path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()));
}
}
}
assert!(files > 10, "found the shaders: {files}");
assert!(bad.is_empty(), "WGSL a strict front-end rejects:\n{}", bad.join("\n"));
}