Files
effectcraft/crates/gpu/src/tests_fx_warp.rs
T
Brandon ThomasandClaude Opus 5.5 9ff0e5aa3c M13.13: gpu texture pool, Auto per-comp backend choice, warp-family GPU ports
- gpu: pooled working textures (released once every encoder that could read
  them is submitted), shared zero images, reused readback staging buffers,
  direct readback into Image; NaN-poisoned scratch images under test and
  NaN-aware diffs; validation errors panic under test
- render: AutoPick (per comp / scale / path EWMA of CPU vs GPU frame times,
  warm-up and periodic re-probe) behind Backend::Auto; viewer uses it too;
  cached adjustment footprints (uploaded once)
- gpu fx_warp: Warp, Bezier Warp, CC Bend It, CC Page Turn, Smear, Reshape,
  Color Emboss, Cartoon; sliding-histogram median for large radii; CC Power
  Pin perspective < 100
- cli: bench --gpu reports Auto and per-frame upload / readback MB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 10:31:31 -04:00

192 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU 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 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, 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))]);
// Fisheye / Twist with a distortion render on the CPU (see catalog::gpu_supported).
let spec = effectcraft_effects::find("ec.distort.warp").unwrap();
let mut params = effectcraft_effects::Params {
values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, [70.0, 44.0]))).collect(),
};
params.values.insert("warpStyle".into(), e(14));
params.values.insert("verticalDistortion".into(), n(-35.0));
let ctx = effectcraft_effects::EffectCtx { params: &params, time: 0.0, layer_size: [70.0, 44.0], seed: 0, adjustment: false, env: Default::default() };
assert!(!effectcraft_effects::catalog::gpu_supported("ec.distort.warp", &ctx));
effect_case("ec.distort.warp", &[("warpStyle", e(3)), ("bend", n(0.0)), ("horizontalDistortion", n(-40.0)), ("verticalDistortion", n(25.0))]);
}
#[test]
fn bezier_warp() {
effect_case("ec.distort.bezierwarp", &[]);
effect_case(
"ec.distort.bezierwarp",
&[("topLeftVertex", pt(8.0, -4.0)), ("topLeftTangent", pt(30.0, 12.0)), ("rightBottomTangent", pt(80.0, 30.0)), ("leftBottomVertex", pt(-6.0, 50.0))],
);
effect_case("ec.distort.bezierwarp", &[("topRightTangent", pt(40.0, -20.0)), ("bottomLeftTangent", pt(20.0, 60.0)), ("quality", n(3.0))]);
}
#[test]
fn bend_it_and_page_turn() {
effect_case("ec.distort.ccbendit", &[("bend", n(45.0))]);
effect_case("ec.distort.ccbendit", &[("bend", n(-120.0)), ("start", pt(10.0, 30.0)), ("end", pt(55.0, 8.0)), ("renderPrestart", e(1))]);
// A slight bend: a large arc radius.
effect_case("ec.distort.ccbendit", &[("bend", n(0.05)), ("start", pt(5.0, 22.0)), ("end", pt(60.0, 22.0))]);
effect_case("ec.distort.ccpageturn", &[]);
effect_case("ec.distort.ccpageturn", &[("foldRadius", n(4.0)), ("foldDirection", n(30.0)), ("foldPosition", pt(40.0, 20.0))]);
effect_case(
"ec.distort.ccpageturn",
&[("render", e(2)), ("backPageOpacity", n(40.0)), ("paperColor", c(0.9, 0.85, 0.7)), ("lightDirection", n(120.0)), ("foldPosition", pt(30.0, 25.0))],
);
effect_case("ec.distort.ccpageturn", &[("render", e(1)), ("foldPosition", pt(50.0, 10.0))]);
}
#[test]
fn color_emboss_and_cartoon() {
effect_case("ec.stylize.coloremboss", &[]);
effect_case("ec.stylize.coloremboss", &[("direction", n(30.0)), ("relief", n(3.5)), ("contrast", n(250.0)), ("blend", n(30.0))]);
effect_case("ec.stylize.cartoon", &[]);
effect_case("ec.stylize.cartoon", &[("render", e(0)), ("detailRadius", n(4.0)), ("fill/shadingSteps", n(5.0)), ("fill/shadingSmoothness", n(0.0))]);
effect_case("ec.stylize.cartoon", &[("render", e(1)), ("edge/edgeWidth", n(0.6)), ("advanced/edgeBlackLevel", n(40.0)), ("advanced/edgeContrast", n(0.8))]);
effect_case("ec.stylize.cartoon", &[("edge/edgeWidth", n(3.3)), ("advanced/edgeEnhancement", n(60.0)), ("edge/edgeSoftness", n(20.0))]);
effect_case("ec.stylize.cartoon", &[("advanced/edgeEnhancement", n(-50.0)), ("detailRadius", n(0.0))]);
}
/// Mask outlines in layer pixels (70 × 44 layers): a source blob, a destination blob and a
/// boundary around both.
fn outlines() -> Vec<MaskShape> {
let ellipse = |cx: f64, cy: f64, rx: f64, ry: f64| -> Vec<[f64; 2]> {
(0..40).map(|i| i as f64 / 40.0 * std::f64::consts::TAU).map(|a| [cx + rx * a.cos(), cy + ry * a.sin()]).collect()
};
let shape = |name: &str, points| MaskShape { name: name.into(), points, closed: true, inverted: false };
vec![shape("Mask 1", ellipse(25.0, 20.0, 9.0, 7.0)), shape("Mask 2", ellipse(42.0, 24.0, 12.0, 6.0)), shape("Mask 3", ellipse(34.0, 22.0, 30.0, 19.0))]
}
/// The effect alone on a buffer with masks (`effect_direct` with the layer's masks).
fn direct_with_masks(id: &str, vals: &[(&str, Value)]) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
let masks = outlines();
let mut params =
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
params.values.insert(k.to_string(), v.clone());
}
for adjustment in [false, true] {
for scale in [1.0, 0.5] {
let img = pattern(7, (size[0] * scale) as u32, (size[1] * scale) as u32);
let buf = effectcraft_effects::Buf { img, offset: [0.0, 0.0], scale };
let ctx = || effectcraft_effects::EffectCtx {
params: &params,
time: 0.25,
layer_size: size,
seed: 11,
adjustment,
env: effectcraft_effects::EffectEnv { masks: &masks, ..Default::default() },
};
let cpu = (spec.render)(&ctx(), buf.clone());
let out =
effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx: ctx() }], &buf, None).expect("the GPU runs the effect");
assert_eq!((out.offset, out.scale), (cpu.offset, cpu.scale), "{id}: geometry");
assert!(cpu.img.data != buf.img.data, "{id} {vals:?}: the case changes the picture");
let d = diff(&cpu.img, &out.img, 1.0 / 255.0);
// f32 point-in-polygon tests may flip a pixel exactly on an outline.
assert!(
d.over * 1000 <= d.total,
"{id} {vals:?} adj {adjustment} scale {scale}: {} of {} pixels over 1/255 (max {}); worst {:?}",
d.over,
d.total,
d.max,
d.worst
);
}
}
}
/// Composited at 8 and 32 bpc, with the masks on the layer (mode None: they only feed the effect).
fn composite_with_masks(id: &str, vals: &[(&str, Value)]) {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut l = s.footage(70, 44);
let mut next = s.p.next_id;
for m in outlines() {
let path = ShapePath {
vertices: m.points.clone(),
in_tangents: vec![[0.0; 2]; m.points.len()],
out_tangents: vec![[0.0; 2]; m.points.len()],
closed: true,
..Default::default()
};
let g = build::mask(&mut Ids(&mut next), &m.name, path, MaskMode::None, [255, 255, 0]);
l.props.sub_mut("masks").unwrap().children.push(g.into());
}
s.p.next_id = next;
s.effect(&mut l, id, vals);
s.push(l);
check(&format!("{id} {vals:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.003);
check(&format!("{id} {vals:?} {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.003);
}
}
#[test]
fn smear_and_reshape() {
let smear: &[&[(&str, Value)]] = &[
&[("sourceMask", e(1)), ("boundaryMask", e(3)), ("maskOffset", pt(10.0, 3.0))],
&[
("sourceMask", e(1)),
("boundaryMask", e(3)),
("maskOffset", pt(-4.0, 6.0)),
("maskRotation", n(30.0)),
("maskScale", n(130.0)),
("elasticity", e(6)),
("percent", n(70.0)),
],
];
for vals in smear {
direct_with_masks("ec.distort.smear", vals);
composite_with_masks("ec.distort.smear", vals);
}
let reshape: &[&[(&str, Value)]] = &[
&[("sourceMask", e(1)), ("destinationMask", e(2))],
&[("sourceMask", e(1)), ("destinationMask", e(2)), ("boundaryMask", e(3)), ("interpolation", e(2)), ("elasticity", e(0))],
&[
("sourceMask", e(1)),
("destinationMask", e(2)),
("boundaryMask", e(3)),
("correspondencePoints", Value::Str("0,0.25 0.5,0.8".into())),
("percent", n(60.0)),
],
];
for vals in reshape {
direct_with_masks("ec.distort.reshape", vals);
composite_with_masks("ec.distort.reshape", vals);
}
}