mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
337 lines
16 KiB
Rust
337 lines
16 KiB
Rust
//! 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, diff, gpu, n, opts, set, tolerance};
|
||
|
||
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 wide_shadow_diffusion() {
|
||
// #2444: a wide penumbra takes more than the 3 × 3 kernel's samples; both sides pick the
|
||
// same count.
|
||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||
let mut s = crossing(depth, BlendMode::Normal);
|
||
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
|
||
if l.switches.three_d {
|
||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||
}
|
||
}
|
||
light(&mut s, LightKind::Point, [150.0, 20.0, -80.0], &[("castsShadows", Value::Bool(true)), ("shadowDiffusion", n(60.0))]);
|
||
check(&format!("wide shadow diffusion {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.find_mut(|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].into();
|
||
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);
|
||
if !g.context().has_kernel("classic3d") {
|
||
// FXC (DirectX 12) can't build the kernel (#613); the CPU fallback is tested below.
|
||
return;
|
||
}
|
||
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);
|
||
if g.context().has_kernel("classic3d") {
|
||
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.find_mut(|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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// #613: FXC on DirectX 12 fails to build `classic3d` (it can't unroll its loops). The
|
||
/// compositor still starts without it, lists it, and 3D runs render on the CPU, matching the
|
||
/// CPU render.
|
||
#[test]
|
||
fn classic_3d_runs_render_on_the_cpu_without_the_kernel() {
|
||
let Some(base) = gpu() else {
|
||
return;
|
||
};
|
||
let ctx = base.context().without_kernel("classic3d");
|
||
assert!(!ctx.has_kernel("classic3d"));
|
||
let g = crate::Gpu::from_context(ctx);
|
||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||
let mut s = crossing(depth, BlendMode::Normal);
|
||
camera(&mut s, [30.0, 20.0, -260.0], None);
|
||
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts() }).comp_frame_cpu(s.cid, Tick::ZERO);
|
||
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
|
||
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(depth));
|
||
d.quantized = depth != BitDepth::Bpc32;
|
||
check(&format!("classic 3D without its kernel {depth:?}"), Some(d), 0.0);
|
||
}
|
||
}
|