Files
effectcraft/crates/render/src/three_d/tests.rs
T
BFlat e2128a6743 Merge pull request #2118 from ChristopherLinnett/fix/issue-2092-camera-focus-crash
fix: bound Classic 3D depth-of-field blur and reject non-finite focus distances (#2092)
2026-10-30 05:09:17 +07:00

722 lines
32 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.
use effectcraft_color::Label;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value;
use effectcraft_project::build;
use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, LightKind, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use super::camera::{Rig, View3D, default_view_cam, orientation_for};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer, render_frame};
const W: u32 = 200;
const H: u32 = 100;
fn setup() -> (Project, ItemId, Comp) {
setup_sized(W, H)
}
/// [`setup`] with a `w`×`h` comp.
fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) {
let mut p = Project::default();
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
/// A 3D solid of `w`×`h` centred at `pos`.
fn solid3(p: &mut Project, comp: &Comp, color: [f32; 3], w: u32, h: u32, pos: [f64; 3]) -> Layer {
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
let mut l = build::layer(p, comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
l
}
fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn push(p: &mut Project, cid: ItemId, l: Layer) {
// Appended = lowest in the stack.
p.comp_mut(cid).unwrap().layers.push(l);
}
fn render(p: &Project, cid: ItemId) -> effectcraft_raster::Image {
render_frame(p, cid, Tick::ZERO, 1.0)
}
fn close(a: f32, b: f32, eps: f32) -> bool {
(a - b).abs() <= eps
}
fn zoom() -> f64 {
W as f64 * 50.0 / 36.0
}
// ---------------------------------------------------------------- projection
#[test]
fn z0_plane_matches_2d() {
let (mut p, cid, comp) = setup();
let l = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 20, [50.0, 30.0, 0.0]);
push(&mut p, cid, l);
let img = render(&p, cid);
// Covers x 30..70, y 20..40.
assert_eq!(img.get(31, 21), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(68, 38), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(29, 30)[3], 0.0);
assert_eq!(img.get(71, 30)[3], 0.0);
}
#[test]
fn farther_layers_project_smaller() {
let (mut p, cid, comp) = setup();
// At z = zoom the plane is twice as far from the eye: half size around the comp centre.
let l = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 80, 40, [100.0, 50.0, zoom()]);
push(&mut p, cid, l);
let img = render(&p, cid);
// 40×20 centred on (100, 50): x 80..120, y 40..60.
assert!(img.get(82, 42)[3] > 0.99);
assert!(img.get(117, 57)[3] > 0.99);
assert!(img.get(77, 50)[3] < 0.01, "{:?}", img.get(77, 50));
assert!(img.get(100, 37)[3] < 0.01);
}
#[test]
fn projection_math_matches_camera_state() {
let cam = default_camera(1920.0, 1080.0);
let pr = cam.projection(1920.0, 1080.0);
let p = pr.apply(vec3(100.0, 200.0, 0.0));
assert!((p.x - 100.0).abs() < 1e-6 && (p.y - 200.0).abs() < 1e-6);
// A point at depth 2·zoom maps halfway to the centre.
let q = cam.project(1920.0, 1080.0, vec3(0.0, 0.0, cam.zoom)).unwrap();
assert!((q.x - 480.0).abs() < 1e-6 && (q.y - 270.0).abs() < 1e-6, "{q:?}");
assert!(cam.project(1920.0, 1080.0, vec3(960.0, 540.0, -cam.zoom - 10.0)).is_none());
}
#[test]
fn y_rotation_foreshortens_with_correct_perspective() {
let (mut p, cid, comp) = setup();
let mut l = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 60, [100.0, 50.0, 0.0]);
set(&mut l, "transform/rotationY", Value::Scalar(60.0));
push(&mut p, cid, l);
let img = render(&p, cid);
// cos 60° = 0.5: roughly half as wide; the near edge (right, z<0) is taller than the far edge.
assert!(img.get(100, 50)[3] > 0.99);
assert!(img.get(140, 50)[3] < 0.01);
let col_h = |x: u32| (0..H).filter(|&y| img.get(x as i64, y as i64)[3] > 0.5).count();
assert!(col_h(122) > col_h(78), "near edge {} vs far edge {}", col_h(122), col_h(78));
}
#[test]
fn layer_behind_camera_is_clipped() {
let (mut p, cid, comp) = setup();
let l = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100, [100.0, 50.0, -zoom() - 50.0]);
push(&mut p, cid, l);
let img = render(&p, cid);
assert!(img.data.iter().all(|px| px[3] == 0.0));
}
// ---------------------------------------------------------------- depth ordering
#[test]
fn nearer_layer_wins_regardless_of_stack_order() {
let (mut p, cid, comp) = setup();
let far = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 600, 300, [100.0, 50.0, 200.0]);
let near = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 40, 40, [100.0, 50.0, 0.0]);
// Far layer is on top of the stack.
push(&mut p, cid, far);
push(&mut p, cid, near);
let img = render(&p, cid);
assert_eq!(img.get(100, 50), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(img.get(5, 50)[0], 1.0);
}
#[test]
fn coplanar_layers_keep_stack_order() {
let (mut p, cid, comp) = setup();
let top = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 40, [100.0, 50.0, 30.0]);
let bottom = solid3(&mut p, &comp, [0.0, 0.0, 1.0], 80, 80, [100.0, 50.0, 30.0]);
push(&mut p, cid, top);
push(&mut p, cid, bottom);
let img = render(&p, cid);
assert_eq!(img.get(100, 50), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn intersecting_planes_split_per_pixel() {
let (mut p, cid, comp) = setup();
let flat = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 160, 80, [100.0, 50.0, 0.0]);
let mut tilted = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 160, 80, [100.0, 50.0, 0.0]);
// Rotated about Y: its right half comes towards the camera, its left half goes behind.
set(&mut tilted, "transform/rotationY", Value::Scalar(40.0));
push(&mut p, cid, tilted);
push(&mut p, cid, flat);
let img = render(&p, cid);
assert_eq!(img.get(125, 50), [0.0, 1.0, 0.0, 1.0], "right: tilted plane in front");
assert_eq!(img.get(75, 50), [1.0, 0.0, 0.0, 1.0], "left: flat plane in front");
}
#[test]
fn two_d_layer_breaks_3d_group() {
let (mut p, cid, comp) = setup();
let far_top = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 60, 60, [100.0, 50.0, 300.0]);
let sid = p.add_item("S2", Label::Red, None, ItemKind::Solid(Solid { color: [0.0, 0.0, 1.0], width: 20, height: 100, pixel_aspect: 1.0 }));
let mut two_d = build::layer(&mut p, &comp, "2D", LayerSource::Solid { item: sid }, (20, 100), None);
set(&mut two_d, "transform/position", Value::Vec3([100.0, 50.0, 0.0]));
let near_bottom = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 200, 100, [100.0, 50.0, 0.0]);
// Stack: far 3D (top), 2D, near 3D (bottom). The far layer is a separate group above the
// 2D layer, so it draws over everything even though it is farther away.
push(&mut p, cid, far_top);
push(&mut p, cid, two_d);
push(&mut p, cid, near_bottom);
let img = render(&p, cid);
assert_eq!(img.get(100, 50), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(img.get(5, 5), [0.0, 1.0, 0.0, 1.0]);
}
// ---------------------------------------------------------------- lighting
fn light(p: &mut Project, comp: &Comp, kind: LightKind, pos: [f64; 3]) -> Layer {
let mut l = build::layer(p, comp, "Light", LayerSource::Light { kind }, (W, H), None);
if kind != LightKind::Ambient {
set(&mut l, "transform/position", Value::Vec3(pos));
}
l
}
fn no_specular(l: &mut Layer) {
set(l, "materialOptions/specularIntensity", Value::Scalar(0.0));
}
#[test]
fn head_on_point_light_scales_by_diffuse() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [1.0, 0.8, 0.6], 200, 100, [100.0, 50.0, 0.0]);
no_specular(&mut s);
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -500.0]);
push(&mut p, cid, l);
push(&mut p, cid, s);
let img = render(&p, cid);
let c = img.get(100, 50);
// Diffuse 50% · N·L = 1 · intensity 100%.
assert!(close(c[0], 0.5, 1e-3) && close(c[1], 0.4, 1e-3) && close(c[2], 0.3, 1e-3), "{c:?}");
// Off-axis pixels are lit at a grazing angle → darker.
assert!(img.get(2, 2)[0] < c[0]);
}
#[test]
fn ambient_light_uses_ambient_coefficient() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [0.8, 0.8, 0.8], 200, 100, [100.0, 50.0, 0.0]);
set(&mut s, "materialOptions/ambient", Value::Scalar(50.0));
let mut l = light(&mut p, &comp, LightKind::Ambient, [0.0; 3]);
set(&mut l, "lightOptions/intensity", Value::Scalar(50.0));
push(&mut p, cid, l);
push(&mut p, cid, s);
let c = render(&p, cid).get(10, 10);
assert!(close(c[0], 0.8 * 0.5 * 0.5, 1e-4), "{c:?}");
}
#[test]
fn lights_off_or_not_accepting_lights_render_unlit() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [0.3, 0.6, 0.9], 200, 100, [100.0, 50.0, 0.0]);
set(&mut s, "materialOptions/acceptsLights", Value::Bool(false));
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -500.0]);
push(&mut p, cid, l);
push(&mut p, cid, s);
let c = render(&p, cid).get(100, 50);
assert!(close(c[0], 0.3, 1e-5) && close(c[2], 0.9, 1e-5), "{c:?}");
}
#[test]
fn light_behind_layer_needs_transmission() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 0.0]);
no_specular(&mut s);
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, 500.0]);
push(&mut p, cid, l);
push(&mut p, cid, s.clone());
assert!(render(&p, cid).get(100, 50)[0] < 1e-4);
// 100% transmission passes the light through the layer.
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[1], "materialOptions/lightTransmission", Value::Scalar(100.0));
let v = render(&p, cid).get(100, 50)[0];
assert!(close(v, 0.5, 1e-3), "{v}");
}
#[test]
fn specular_highlight_adds_light() {
let (mut p, cid, comp) = setup();
let s = solid3(&mut p, &comp, [0.5, 0.5, 0.5], 200, 100, [100.0, 50.0, 0.0]);
let l = light(&mut p, &comp, LightKind::Point, [100.0, 50.0, -zoom()]);
push(&mut p, cid, l);
push(&mut p, cid, s);
let c = render(&p, cid).get(100, 50);
// 0.5·0.5 diffuse + 0.5 specular (N·H = 1) tinted by the layer colour (Metal 100%) = 0.5.
assert!(close(c[0], 0.25 + 0.25, 2e-3), "{c:?}");
}
#[test]
fn spot_cone_limits_light() {
let (mut p, cid, comp) = setup();
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 0.0]);
no_specular(&mut s);
let mut l = light(&mut p, &comp, LightKind::Spot, [100.0, 50.0, -200.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/coneAngle", Value::Scalar(20.0));
set(&mut l, "lightOptions/coneFeather", Value::Scalar(0.0));
push(&mut p, cid, l);
push(&mut p, cid, s);
let img = render(&p, cid);
assert!(img.get(100, 50)[0] > 0.45);
// tan(10°)·200 ≈ 35 px radius.
assert!(img.get(160, 50)[0] < 1e-4);
}
#[test]
fn falloff_inverse_square_clamped() {
let mut ls = LightState::from_layer(&crate::EvalCtx::new(&Project::default(), ItemId(0), &Comp::new(W, H, FrameRate::FPS_30, Tick::ZERO), Tick::ZERO), &{
let (mut p, _, comp) = setup();
light(&mut p, &comp, LightKind::Point, [0.0, 0.0, 0.0])
})
.unwrap();
ls.falloff = 2;
ls.radius = 100.0;
assert!((ls.attenuation(vec3(50.0, 0.0, 0.0)) - 1.0).abs() < 1e-12);
assert!((ls.attenuation(vec3(200.0, 0.0, 0.0)) - 0.25).abs() < 1e-12);
ls.falloff = 1;
ls.falloff_distance = 100.0;
assert!((ls.attenuation(vec3(150.0, 0.0, 0.0)) - 0.5).abs() < 1e-12);
assert_eq!(ls.attenuation(vec3(250.0, 0.0, 0.0)), 0.0);
}
// ---------------------------------------------------------------- shadows
#[test]
fn caster_shadows_receiver() {
let (mut p, cid, comp) = setup();
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100, [100.0, 50.0, 100.0]);
no_specular(&mut recv);
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 20, 20, [60.0, 50.0, 0.0]);
no_specular(&mut caster);
set(&mut caster, "materialOptions/castsShadows", Value::Enum(1));
// Parallel light straight along +z: the shadow lands right behind the caster.
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
push(&mut p, cid, l);
push(&mut p, cid, caster);
push(&mut p, cid, recv);
let img = render(&p, cid);
// Lit receiver away from the shadow: 0.5.
assert!(close(img.get(150, 50)[0], 0.5, 1e-3));
// Light from the left (direction (300, 0, 500)): the shadow shifts +60 px at the receiver's
// depth, to world x 110..130 → screen ≈ 107..122.
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[0], "transform/position", Value::Vec3([100.0 - 300.0, 50.0, -500.0]));
let img = render(&p, cid);
let lit = img.get(150, 50)[0];
let expect = 0.5 * (500.0 / (300.0f64 * 300.0 + 500.0 * 500.0).sqrt()) as f32;
assert!(close(lit, expect, 1e-3), "lit {lit} vs {expect}");
assert!(img.get(115, 50)[0] < 1e-3, "shadowed {:?}", img.get(115, 50));
assert!(close(img.get(60, 50)[0], expect, 1e-3), "caster itself is lit");
}
#[test]
fn shadow_darkness_and_accepts_off() {
let (mut p, cid, comp) = setup();
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]);
no_specular(&mut recv);
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 0.0]);
set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
set(&mut l, "lightOptions/shadowDarkness", Value::Scalar(50.0));
push(&mut p, cid, l);
push(&mut p, cid, caster);
push(&mut p, cid, recv);
let v = render(&p, cid).get(100, 50)[0];
assert!(close(v, 0.25, 1e-3), "half-dark shadow: {v}");
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[2], "materialOptions/acceptsShadows", Value::Enum(0));
let v = render(&p, cid).get(100, 50)[0];
assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}");
}
#[test]
fn shadow_diffusion_is_a_soft_ramp_not_copies() {
// #2444: a wide Shadow Diffusion stamped 3 × 3 copies of the caster, so the penumbra stepped
// through three flat levels instead of ramping smoothly from lit to shadowed.
let (mut p, cid, comp) = setup();
let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]);
no_specular(&mut recv);
let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 200, [40.0, 50.0, 0.0]);
set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster
let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);
set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));
set(&mut l, "lightOptions/castsShadows", Value::Bool(true));
// 100 px to the caster over the parallel light's 1000: a 15 px penumbra radius.
set(&mut l, "lightOptions/shadowDiffusion", Value::Scalar(150.0));
push(&mut p, cid, l);
push(&mut p, cid, caster);
push(&mut p, cid, recv);
let img = render(&p, cid);
let row: Vec<f32> = (0..i64::from(W)).map(|x| img.get(x, 50)[0]).collect();
// The row crosses both vertical edges of the caster's shadow: lit, ramp, shadowed, ramp, lit.
assert!(row.iter().any(|v| *v < 0.01) && close(row[W as usize - 1], 0.5, 1e-3), "shadow and light: {row:?}");
let ramp = row.iter().filter(|v| **v > 0.02 && **v < 0.48).count();
assert!(ramp >= 16, "two penumbras several pixels wide: {row:?}");
let step = row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);
assert!(step < 0.08, "no jumps between copies of the caster (largest step {step}): {row:?}");
}
// ---------------------------------------------------------------- cameras
fn camera(p: &mut Project, comp: &Comp) -> Layer {
build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None)
}
#[test]
fn default_camera_layer_matches_comp_camera() {
let (mut p, cid, comp) = setup();
let cam = camera(&mut p, &comp);
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value.as_f64(), zoom());
let s = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 20, [50.0, 30.0, 0.0]);
push(&mut p, cid, s.clone());
let a = render(&p, cid);
p.comp_mut(cid).unwrap().layers.insert(0, cam);
let b = render(&p, cid);
assert_eq!(a, b);
}
#[test]
fn two_node_camera_looks_at_poi() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
// Look at a layer far to the right: it lands in the middle of the frame.
set(&mut cam, "transform/poi", Value::Vec3([600.0, 50.0, 0.0]));
set(&mut cam, "transform/position", Value::Vec3([600.0, 50.0, -zoom()]));
let s = solid3(&mut p, &comp, [0.0, 0.0, 1.0], 20, 20, [600.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
assert!(img.get(100, 50)[2] > 0.99);
assert!(img.get(80, 50)[3] < 0.01);
}
#[test]
fn one_node_camera_uses_orientation() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
cam.auto_orient = effectcraft_project::AutoOrient::Off;
set(&mut cam, "transform/position", Value::Vec3([100.0, 50.0, 0.0]));
// Turn to look along +x.
set(&mut cam, "transform/orientation", Value::Vec3(orientation_for(vec3(1.0, 0.0, 0.0))));
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 0.0], 40, 40, [400.0, 50.0, 0.0]);
set(&mut s, "transform/rotationY", Value::Scalar(90.0));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
assert!(img.get(100, 50)[3] > 0.99, "{:?}", img.get(100, 50));
}
#[test]
fn orientation_for_round_trips() {
for d in [vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), vec3(-0.3, 0.4, 0.8), vec3(0.2, -0.9, -0.1)] {
let o = orientation_for(d);
let f = effectcraft_geom::Mat4::orientation(Vec3::from(o)).apply_vec(vec3(0.0, 0.0, 1.0));
assert!((f - d.normalize()).length() < 1e-9, "{d:?} → {o:?} → {f:?}");
}
}
#[test]
fn depth_of_field_blurs_out_of_focus_layers() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
// In focus (focus distance = zoom = distance to z=0): hard edge at x = 70.
let a = render(&p, cid);
assert!(a.get(71, 50)[3] > 0.99 && a.get(69, 50)[3] < 0.01);
let c = p.comp_mut(cid).unwrap();
set(&mut c.layers[0], "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
let b = render(&p, cid);
let e = b.get(70, 50)[3];
assert!(e > 0.1 && e < 0.9, "soft edge {e}");
assert!(b.get(66, 50)[3] > 0.01);
}
/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's
/// default plane (`zoom`) instead.
#[test]
fn dof_falls_back_when_focus_distance_is_not_finite() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
let cp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO);
let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]);
let f = cs.dof.unwrap().focus;
assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom());
// The z = 0 plane is in focus: hard edge, finite pixels.
let img = render(&p, cid);
assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard");
assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels");
}
}
/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal
/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a
/// radius of billions of buffer pixels, whose padded buffer can't be allocated.
#[test]
fn dof_blur_radius_is_bounded_for_degenerate_inputs() {
let scene = |focus: f64, scale: f64| {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
push(&mut p, cid, cam);
push(&mut p, cid, s);
render(&p, cid)
};
let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite()));
// A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9).
assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels");
// Huge but finite Focus Distances (typed in, or a large expression result): the blur is as
// large as the buffer budget allows, never refused (which would render the layer sharp).
// 1e307 overflows the circle of confusion itself to infinity: still the largest blur.
for focus in [1.0e12, 1.0e307] {
let img = scene(focus, 100.0);
assert!(finite(&img), "focus {focus:e}: non-finite pixels");
assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]);
}
}
/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred;
/// any other radius stays within the buffer budget's cap.
#[test]
fn dof_radii_skip_degenerate_planes_and_respect_the_cap() {
let radii = |focus: f64, scale: f64| {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let cp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO);
let cs = super::camera::layer_camera(&ctx, &cp.layers[0]);
let dof = cs.dof.unwrap();
let cap = super::compose::dof_radius_cap(&dof, 60, 60);
(super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap)
};
let (r, _) = radii(zoom() + 10.0, 0.0);
assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred");
let (r, cap) = radii(1.0e12, 100.0);
assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}");
assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}");
}
/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth
/// blur by the same amount on screen: the radius bound must not depend on the layer's scale.
#[test]
fn dof_blur_is_independent_of_layer_scale() {
let ramp = |w: u32, h: u32, scale: f64| {
let (mut p, cid, comp) = setup_sized(800, 400);
let zoom = 800.0 * 50.0 / 36.0;
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0));
// coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom).
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0));
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]);
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
soft_count(&img, (0..400).map(|x| (x, 200)))
};
let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0));
assert!(full > 60, "the out-of-focus edge should be soft: {full}");
assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%");
}
/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius
/// (~100 px) is large so that any bound that depends on the resolution would show.
#[test]
fn dof_blur_matches_across_render_resolutions() {
let (mut p, cid, comp) = setup_sized(800, 400);
let zoom = 800.0 * 50.0 / 36.0;
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
let ramp = |scale: f64| {
let img = render_frame(&p, cid, Tick::ZERO, scale);
let (w, h) = (img.width as i64, img.height as i64);
soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale
};
let (full, half) = (ramp(1.0), ramp(0.5));
assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px");
assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px");
}
/// `Dof::coc` treats a non-finite focus or depth as in focus.
#[test]
fn dof_coc_is_zero_for_non_finite_inputs() {
let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}");
assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}");
}
assert!(d(400.0).coc(500.0) > 0.0);
}
/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95.
fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator<Item = (i64, i64)>) -> usize {
pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count()
}
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
soft_count(img, (0..img.height as i64).map(|y| (x, y)))
}
#[test]
fn tilted_layer_blurs_progressively_with_depth() {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(80.0));
set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]);
set(&mut s, "transform/rotationY", Value::Scalar(60.0));
push(&mut p, cid, cam);
push(&mut p, cid, s);
let img = render(&p, cid);
// The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of
// and behind it: soft.
let xs: Vec<i64> = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect();
let (x0, x1) = (xs[0], *xs.last().unwrap());
let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64;
let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9)));
assert!(centre <= 2, "centre {centre}");
assert!(left >= 4 && right >= 4, "ends {left} {right}");
}
#[test]
fn iris_shape_and_highlights_shape_the_bokeh() {
// A small bright square far out of focus: its blur takes the iris' shape.
let spot = |shape: u32, gain: f64| {
let (mut p, cid, comp) = setup();
let mut cam = camera(&mut p, &comp);
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(120.0));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape));
set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain));
set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]);
push(&mut p, cid, cam);
push(&mut p, cid, s);
render(&p, cid)
};
let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count();
let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0));
assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec));
// Fast Rectangle: a box.
let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x)));
let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64;
assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent);
// Highlight Gain brightens the blurred highlight.
let lit = spot(8, 100.0);
let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::<f32>();
assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec));
}
// ---------------------------------------------------------------- views
#[test]
fn view_ids_round_trip() {
for v in View3D::ALL {
assert_eq!(View3D::from_id(v.id()), Some(v));
assert_eq!(View3D::from_id(v.label()), Some(v));
}
assert_eq!(View3D::from_id("Custom View 2"), Some(View3D::Custom2));
}
#[test]
fn front_view_is_orthographic() {
let (mut p, cid, comp) = setup();
// In an orthographic view depth doesn't change size.
let a = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 40, 40, [60.0, 50.0, 0.0]);
let b = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 40, 40, [140.0, 50.0, 1000.0]);
push(&mut p, cid, a);
push(&mut p, cid, b);
let mut vc = default_view_cam(View3D::Front, W as f64, H as f64);
vc.zoom = 1.0;
let opts = RenderOpts { view: Some(vc.state()), ..Default::default() };
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
assert!(img.get(41, 50)[0] > 0.99 && img.get(39, 50)[3] < 0.01);
assert!(img.get(121, 50)[1] > 0.99 && img.get(159, 50)[1] > 0.99 && img.get(161, 50)[3] < 0.01);
}
#[test]
fn top_view_sees_planes_edge_on() {
let (mut p, cid, comp) = setup();
let a = solid3(&mut p, &comp, [1.0, 0.0, 0.0], 100, 100, [100.0, 50.0, 0.0]);
let mut b = solid3(&mut p, &comp, [0.0, 1.0, 0.0], 100, 100, [100.0, 50.0, 0.0]);
set(&mut b, "transform/rotationX", Value::Scalar(90.0));
push(&mut p, cid, a);
push(&mut p, cid, b);
let mut vc = default_view_cam(View3D::Top, W as f64, H as f64);
vc.zoom = 1.0;
let opts = RenderOpts { view: Some(vc.state()), ..Default::default() };
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
// From the top, the X-rotated plane is a 100×100 square; the upright one is a line.
assert!(img.get(100, 30)[1] > 0.99 && img.get(100, 70)[1] > 0.99);
assert!(img.get(100, 30)[0] < 0.01);
}
#[test]
fn rig_orbit_keeps_distance_and_pan_moves_both() {
let vc = default_view_cam(View3D::Custom1, 1920.0, 1080.0);
let mut r = Rig::from_view(&vc);
let d0 = (r.eye - r.poi).length();
r.orbit(37.0, -15.0);
assert!(((r.eye - r.poi).length() - d0).abs() < 1e-6);
let (e, p) = (r.eye, r.poi);
r.pan(10.0, 0.0);
assert!(((r.eye - e) - (r.poi - p)).length() < 1e-9);
r.dolly(100.0, false);
assert!(((r.eye - r.poi).length() - (d0 - 100.0)).abs() < 1e-6);
// Pitch is clamped short of the poles.
r.orbit(0.0, 500.0);
let off = r.eye - r.poi;
assert!(off.y.abs() / off.length() < 0.9999);
}