Files
effectcraft/crates/gpu/src/tests/three_d.rs
T

337 lines
16 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.
//! 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);
}
}