mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Merge branch 'fix/particle-birth-history' into fix/particle-birth-history-ps2-mercury
# Conflicts: # crates/effects/src/sim.rs
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
//! 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::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,
|
||||
/// an environment light, 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));
|
||||
});
|
||||
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 }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.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(>.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(>.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);
|
||||
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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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))]);
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms,
|
||||
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()))]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))]);
|
||||
}
|
||||
@@ -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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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))],
|
||||
);
|
||||
}
|
||||
@@ -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: ¶ms, 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));
|
||||
}
|
||||
@@ -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))]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms, 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: ¶ms, 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: ¶ms, 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))]);
|
||||
}
|
||||
@@ -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: ¶ms, 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))]);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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", &[]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ¶ms, 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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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: ¶ms,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// A host whose Birth Rate is `rate(layer time)` (#1373), with the GPU backend or without (the
|
||||
/// CPU oracle).
|
||||
struct Keyed {
|
||||
gpu: Option<&'static Gpu>,
|
||||
rate: fn(f64) -> f64,
|
||||
}
|
||||
|
||||
impl EffectHost for Keyed {
|
||||
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> {
|
||||
self.gpu.map(|g| g as &dyn ParticleSim)
|
||||
}
|
||||
fn param_at(&self, _effect_index: usize, id: &str, t: f64) -> Option<Value> {
|
||||
(id == "birthRate").then(|| n((self.rate)(t)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Effect `id`'s live particles at layer time `t` through `host`.
|
||||
fn keyed_state(id: &str, p: &Params, t: f64, host: &Keyed) -> Vec<SimParticle> {
|
||||
let env = EffectEnv { host: Some(host), ..Default::default() };
|
||||
let ctx = EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
|
||||
effectcraft_effects::particle_state(id, &ctx).unwrap()
|
||||
}
|
||||
|
||||
/// 3 a second from 0 to 0.5 s.
|
||||
fn early(t: f64) -> f64 {
|
||||
if t < 0.5 { 3.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// 3 a second from 0.25 to 0.75 s: as many births by 0.9 s as [`early`], at other steps.
|
||||
fn late(t: f64) -> f64 {
|
||||
if (0.25..0.75).contains(&t) { 3.0 } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Effect `id` (parameters `p`) under the [`early`] then the [`late`] Birth Rate history: the
|
||||
/// GPU's particles match the CPU's, and each really came from the GPU.
|
||||
fn keyed_agree(id: &str, p: &Params, g: &'static Gpu) {
|
||||
for (rate, times) in [(early as fn(f64) -> f64, [0.4, 0.9, 1.2]), (late, [0.9, 1.2, 0.6])] {
|
||||
let (cpu_host, gpu_host) = (Keyed { gpu: None, rate }, Keyed { gpu: Some(g), rate });
|
||||
for t in times {
|
||||
let cpu = keyed_state(id, p, t, &cpu_host);
|
||||
let before = crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let gpu = keyed_state(id, p, t, &gpu_host);
|
||||
assert!(crate::particles::DISPATCHES.load(std::sync::atomic::Ordering::Relaxed) > before, "{id} t {t}: simulated on the GPU");
|
||||
agree(&format!("{id} keyed birth rate t {t}"), &cpu, &gpu, 1e-4, cpu.len() / 100 + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #1373: with a keyed Birth Rate the GPU is given the births of each moment, as the CPU
|
||||
/// simulates them: a burst lives on after the rate drops to 0, and a GPU checkpoint of one
|
||||
/// history is not resumed for another with the same system key, even when both have born as
|
||||
/// many particles (180 by 0.9 s here) at other steps.
|
||||
#[test]
|
||||
fn particle_world_with_a_keyed_birth_rate_matches_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let p = params("ec.sim.ccparticleworld", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("extras/randomSeed", n(21.0))]);
|
||||
keyed_agree("ec.sim.ccparticleworld", &p, g);
|
||||
}
|
||||
|
||||
/// The same for CC Particle Systems II (90 births by 0.9 s in both histories).
|
||||
#[test]
|
||||
fn particle_systems_ii_with_a_keyed_birth_rate_matches_the_cpu() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let p = params("ec.sim.ccparticlesystems2", &[("birthRate", n(0.0)), ("longevity", n(1.2)), ("randomSeed", n(22.0))]);
|
||||
keyed_agree("ec.sim.ccparticlesystems2", &p, g);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user