544 lines
23 KiB
Rust
544 lines
23 KiB
Rust
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) {
|
||
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}");
|
||
}
|
||
|
||
// ---------------------------------------------------------------- cameras
|
||
|
||
fn camera(p: &mut Project, comp: &Comp) -> Layer {
|
||
build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), 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);
|
||
}
|
||
|
||
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
|
||
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
|
||
(0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count()
|
||
}
|
||
|
||
#[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);
|
||
}
|