mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 02:27:00 -06:00
291 lines
14 KiB
Rust
291 lines
14 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, 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);
|
|
}
|
|
}
|
|
}
|