Files
effectcraft/crates/render/src/three_d/adv/tests.rs
T
Brandon ThomasandClaude Opus 5.5 07da394ccb M13.8: Advanced 3D iris depth of field, collapsed precomps, extruded strokes
Depth of field in Advanced 3D comps uses the camera's iris (Fast Rectangle, Triangle to
Decagon, rotation, roundness, aspect, diffraction fringe) and highlight gain/threshold/
saturation through the Classic 3D bokeh kernel. Collapsed precomps in an Advanced 3D comp
add their nested layers as real geometry (meshes, extrusions, cards) lit by the parent;
2D collapsed precomps draw nested 3D layers with the parent's renderer. Text and shape
strokes extrude as bevelled meshes stacked in paint order (model extrude_stacked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:47:25 -04:00

538 lines
26 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_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build};
use effectcraft_time::{FrameRate, Tick};
use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};
use super::shade::{self, PI};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer};
// ---------------------------------------------------------------- shading
#[test]
fn brdf_matches_cook_torrance_formula() {
// Reference: f = kd·c/π + D·G·F/(4·NV·NL), returned × NL × π.
let n = [0.0, 0.0, 1.0];
let v = shade::norm([0.3, 0.1, 1.0]);
let l = shade::norm([-0.4, 0.2, 1.0]);
let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32);
let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l);
let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]);
let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h));
let a2 = (rough * rough).powi(2);
let d = a2 / (PI * ((nh * nh) * (a2 - 1.0) + 1.0).powi(2));
let k = (rough + 1.0).powi(2) / 8.0;
let g = nv / (nv * (1.0 - k) + k) * nl / (nl * (1.0 - k) + k);
for c in 0..3 {
let f0 = 0.04 + (base[c] - 0.04) * metal;
let f = f0 + (1.0 - f0) * (1.0 - vh).powi(5);
let expect = ((1.0 - f) * (1.0 - metal) * base[c] / PI + d * g * f / (4.0 * nv * nl)) * nl * PI;
assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]);
}
// No light from behind the surface.
assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]);
// A white rough dielectric lit head-on reflects about its base colour (π folded in).
let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n);
assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}");
}
#[test]
fn ggx_distribution_is_normalised() {
// ∫ D(h)·(n·h) dω = 1 over the hemisphere.
for &rough in &[0.2f32, 0.5, 0.9] {
let a = rough * rough;
let n = 4000;
let mut sum = 0.0f64;
for i in 0..n {
let th = (i as f64 + 0.5) / n as f64 * std::f64::consts::FRAC_PI_2;
let c = th.cos() as f32;
sum += shade::d_ggx(c, a) as f64 * c as f64 * th.sin() * std::f64::consts::FRAC_PI_2 / n as f64 * 2.0 * std::f64::consts::PI;
}
assert!((sum - 1.0).abs() < 0.02, "roughness {rough}: {sum}");
}
}
#[test]
fn constant_environment_has_constant_irradiance() {
let src = vec![[0.25, 0.5, 0.75, 1.0]; 64 * 32];
let irr = irradiance_map(64, 32, &src, 32, 16);
for p in &irr {
for c in 0..3 {
assert!((p[c] - src[0][c]).abs() < 1e-4, "{p:?}");
}
}
// And a sky brighter than the ground lights upward-facing normals more.
let sky: Vec<[f32; 4]> = (0..32).flat_map(|y| (0..64).map(move |_| if y < 16 { [1.0, 1.0, 1.0, 1.0] } else { [0.0, 0.0, 0.0, 1.0] })).collect();
let irr = irradiance_map(64, 32, &sky, 32, 16);
assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]);
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
let d = shade::equirect_dir(u, v, 0.3);
let (u2, v2) = shade::equirect_uv(d, 0.3);
assert!((u - u2).abs() < 1e-4 && (v - v2).abs() < 1e-4);
}
// Up (−Y in AE space) is the top row.
assert!(shade::equirect_uv([0.0, -1.0, 0.0], 0.0).1 < 1e-3);
let (a, b) = shade::env_brdf(1.0, 0.0);
assert!(a + b > 0.9 && a + b <= 1.05);
let (a, b) = shade::env_brdf(0.1, 1.0);
assert!(a >= 0.0 && b > -0.01 && a + b < 1.0);
}
// ---------------------------------------------------------------- rasteriser
/// A scene of axis-aligned quads (z = camera depth) seen by an orthographic identity camera
/// mapping world x/y straight to raster pixels.
fn quad_scene(w: u32, h: u32, quads: &[([f32; 4], f32, [f32; 4])]) -> Scene {
let mut s = Scene { width: w, height: h, ssaa: 1, linear_io: true, ..Scene::default() };
// clip: x → 2x/w − 1, y → 1 − 2y/h, z: depth 0..1000 → 1..0 (nearer = larger).
s.clip = [[2.0 / w as f32, 0.0, 0.0, -1.0], [0.0, -2.0 / h as f32, 0.0, 1.0], [0.0, 0.0, -0.001, 1.0], [0.0, 0.0, 0.0, 1.0]];
s.view = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]];
s.ortho = true;
s.cam_fwd = [0.0, 0.0, 1.0];
for (rect, z, color) in quads {
let m = s.materials.len() as u32;
s.materials.push(Material { base: *color, unlit: true, double_sided: true, alpha_mode: if color[3] < 1.0 { 2 } else { 0 }, ..Material::default() });
let b = s.vertices.len() as u32;
for (x, y) in [(rect[0], rect[1]), (rect[2], rect[1]), (rect[2], rect[3]), (rect[0], rect[3])] {
s.vertices.push(Vertex { pos: [x, y, *z], normal: [0.0, 0.0, -1.0], uv: [0.0; 2], tangent: [1.0, 0.0, 0.0, 1.0], material: m });
}
s.indices.extend([b, b + 2, b + 1, b, b + 3, b + 2]);
}
// Opaque first, transparent (in given order) after.
let tris: Vec<[u32; 3]> = s.indices.chunks(3).map(|t| [t[0], t[1], t[2]]).collect();
let (o, t): (Vec<_>, Vec<_>) = tris.into_iter().partition(|t| !s.materials[s.vertices[t[0] as usize].material as usize].transparent());
s.opaque_count = 3 * o.len() as u32;
s.indices = o.into_iter().chain(t).flatten().collect();
s
}
#[test]
fn depth_buffer_keeps_the_nearest_surface() {
let red = [1.0, 0.0, 0.0, 1.0];
let blue = [0.0, 0.0, 1.0, 1.0];
// Blue is listed first but is farther away.
let s = quad_scene(40, 30, &[([0.0, 0.0, 30.0, 20.0], 500.0, blue), ([10.0, 10.0, 40.0, 30.0], 100.0, red)]);
let t = raster::render(&s);
let px = |x: usize, y: usize| t.color[y * 40 + x];
assert_eq!(px(5, 5), blue);
assert_eq!(px(15, 15), red, "overlap: the nearer quad wins");
assert_eq!(px(35, 25), red);
assert_eq!(px(35, 5), [0.0; 4]);
assert_eq!(t.depth[15 * 40 + 15], 100.0);
assert!(t.depth[5 * 40 + 35].is_infinite());
}
#[test]
fn coverage_is_exact_for_pixel_aligned_quads() {
// Shared diagonal edges are filled once (top-left rule): exact pixel counts.
let s = quad_scene(32, 32, &[([4.0, 4.0, 20.0, 12.0], 10.0, [1.0, 1.0, 1.0, 0.5])]);
let t = raster::render(&s);
let covered = t.color.iter().filter(|c| c[3] > 0.0).count();
assert_eq!(covered, 16 * 8);
// Blended once (no double coverage along the diagonal).
assert!(t.color.iter().all(|c| c[3] == 0.0 || (c[3] - 0.5).abs() < 1e-6));
// Two transparent layers composite in order.
let s = quad_scene(8, 8, &[([0.0, 0.0, 8.0, 8.0], 100.0, [1.0, 0.0, 0.0, 0.5]), ([0.0, 0.0, 8.0, 8.0], 50.0, [0.0, 0.0, 1.0, 0.5])]);
let c = raster::render(&s).color[0];
assert!((c[0] - 0.25).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6 && (c[3] - 0.75).abs() < 1e-6, "{c:?}");
}
#[test]
fn textures_sample_bilinear_with_wrap_modes() {
let t = TexInfo { offset: 0, width: 2, height: 1, wrap_u: 0, wrap_v: 1 };
let texels = vec![[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]];
assert_eq!(shade::sample(&t, &texels, 0.25, 0.5)[0], 0.0);
assert_eq!(shade::sample(&t, &texels, 0.5, 0.5)[0], 0.5);
// Repeat wraps the right edge back to texel 0.
assert!((shade::sample(&t, &texels, 1.0, 0.5)[0] - 0.5).abs() < 1e-6);
let c = TexInfo { wrap_u: 1, ..t };
assert_eq!(shade::sample(&c, &texels, 1.0, 0.5)[0], 1.0);
}
// ---------------------------------------------------------------- full renderer
fn project(w: u32, h: u32) -> (Project, ItemId) {
let mut p = Project::default();
let mut c = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
c.renderer = R3::Advanced3D;
let id = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
(p, id)
}
fn add(p: &mut Project, cid: ItemId, src: LayerSource, edit: impl FnOnce(&mut effectcraft_project::Layer)) {
let comp = p.comp(cid).unwrap().clone();
let mut l = build::layer(p, &comp, "L", src, (comp.width, comp.height), None);
edit(&mut l);
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v;
}
fn render(p: &Project, cid: ItemId) -> crate::Image {
Renderer::new(p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO)
}
#[test]
fn lit_cube_renders_with_depth_and_shading() {
let (mut p, cid) = project(160, 120);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(60.0));
set(l, "geometryOptions/height", Value::Scalar(60.0));
set(l, "geometryOptions/depth", Value::Scalar(60.0));
set(l, "transform/rotationY", Value::Scalar(35.0));
set(l, "transform/rotationX", Value::Scalar(-25.0));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
let img = render(&p, cid);
let centre = img.data[60 * 160 + 80];
assert!(centre[3] > 0.99, "cube covers the centre: {centre:?}");
assert_eq!(img.data[2 * 160 + 2][3], 0.0, "corner is empty");
// No lights: unlit (base colour) — add a light and faces get different shades.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {});
let lit = render(&p, cid);
let mut shades: Vec<i32> = lit.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect();
shades.sort();
shades.dedup();
assert!(shades.len() >= 3, "faces are shaded differently: {shades:?}");
// Classic 3D draws no meshes.
p.comp_mut(cid).unwrap().renderer = R3::Classic3D;
assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0));
}
#[test]
fn shadows_darken_the_receiver() {
let (mut p, cid) = project(200, 150);
// A floor plane facing the camera, a sphere in front of it, a spot light behind the camera.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([100.0, 75.0, -300.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let img = render(&p, cid);
// The sphere casts only (invisible); its shadow lands on the plane behind it.
let mid = img.data[75 * 200 + 100];
let side = img.data[75 * 200 + 20];
assert!(mid[3] > 0.99 && side[3] > 0.99);
assert!(mid[0] < side[0] * 0.5, "shadowed centre {mid:?} vs lit side {side:?}");
// Without shadow casting the centre is lit.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
assert!(img.data[75 * 200 + 100][0] > side[0] * 0.8);
}
#[test]
fn cards_and_environment_light() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
let c = unlit.data[20 * 120 + 30];
assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");
assert_eq!(unlit.data[80 * 120 + 110][3], 0.0);
// An Environment light sourcing a white environment layer brightens the card.
let env = p.add_item(
"E",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
l.environment = true;
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
let lit = render(&p, cid);
let c2 = lit.data[20 * 120 + 30];
assert!(c2[0] > 0.3, "{c2:?}");
// The environment layer itself is not drawn.
assert_eq!(lit.data[80 * 120 + 110][3], 0.0);
}
#[test]
fn depth_of_field_blurs_out_of_focus_surfaces() {
let mut img = Image::new(64, 64);
for y in 0..64 {
for x in 0..64 {
let v = if (x / 4 + y / 4) % 2 == 0 { 1.0 } else { 0.0 };
img.data[y * 64 + x] = [v, v, v, 1.0];
}
}
let var = |im: &Image| {
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
};
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
assert!(var(&blurred) < var(&img) * 0.3, "out of focus: blurred");
}
#[test]
fn extruded_text_renders_as_a_mesh() {
let (mut p, cid) = project(200, 100);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() };
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
set(l, "transform/rotationY", Value::Scalar(40.0));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
assert!(!meshes.is_empty());
assert!(meshes.iter().all(|(m, _, _)| m.positions.iter().any(|q| (q[2] - 30.0).abs() < 1e-3)));
let img = render(&p, cid);
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
// ---------------------------------------------------------------- motion blur, blend modes, mattes
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
p.add_item("S", Default::default(), None, ItemKind::Solid(effectcraft_project::Solid { color, width: w, height: h, pixel_aspect: 1.0 }))
}
/// Pixels of row `y` whose alpha is partial (motion-blur smear or anti-aliasing).
fn partial(img: &crate::Image, y: u32) -> usize {
(0..img.width).filter(|x| (0.05..0.95).contains(&img.data[(y * img.width + x) as usize][3])).count()
}
#[test]
fn motion_blur_smears_moving_meshes() {
let (mut p, cid) = project(160, 120);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(30.0));
set(l, "geometryOptions/height", Value::Scalar(30.0));
set(l, "geometryOptions/depth", Value::Scalar(30.0));
// 60 px per frame to the right.
let pr = l.props.prop_mut("transform/position").unwrap();
pr.keys = vec![
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 60.0, 0.0])),
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0 + 30.0 * 60.0, 60.0, 0.0])),
];
});
let sharp = render(&p, cid);
let hard = partial(&sharp, 60);
assert!(hard <= 4, "no blur without the switch: {hard}");
p.comp_mut(cid).unwrap().layers[0].switches.motion_blur = true;
let blurred = render(&p, cid);
let smear = partial(&blurred, 60);
// A 180° shutter smears over half a frame of motion (≈ 30 px).
assert!(smear > 20, "smeared: {smear}");
// Coverage is conserved: the summed alpha along the row stays about the cube's width.
let sum = |im: &crate::Image| (0..160).map(|x| im.data[60 * 160 + x][3]).sum::<f32>();
assert!((sum(&blurred) - sum(&sharp)).abs() < 4.0, "{} vs {}", sum(&blurred), sum(&sharp));
// The comp's motion blur switch and the render option turn it off.
p.comp_mut(cid).unwrap().enable_motion_blur = false;
assert!(partial(&render(&p, cid), 60) <= 4);
p.comp_mut(cid).unwrap().enable_motion_blur = true;
let r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
assert!(partial(&r.comp_frame(cid, Tick::ZERO), 60) <= 4);
// Shutter angle 0 has no exposure time: no smear.
p.comp_mut(cid).unwrap().shutter_angle = 0.0;
assert!(partial(&render(&p, cid), 60) <= 4);
}
#[test]
fn blend_modes_and_occlusion_in_advanced_3d() {
let (mut p, cid) = project(120, 90);
let red = solid(&mut p, [1.0, 0.0, 0.0], 120, 90);
let gray = solid(&mut p, [0.5, 0.5, 0.5], 60, 40);
let blue = solid(&mut p, [0.0, 0.0, 1.0], 20, 20);
// Back: a red card; middle: a gray Multiply card; front: a small blue card on the left.
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Solid { item: gray }, |l| {
l.switches.three_d = true;
l.blend_mode = effectcraft_color::BlendMode::Multiply;
set(l, "transform/position", Value::Vec3([60.0, 45.0, -20.0]));
set(l, "transform/anchor", Value::Vec3([30.0, 20.0, 0.0]));
});
add(&mut p, cid, LayerSource::Solid { item: blue }, |l| {
l.switches.three_d = true;
set(l, "transform/position", Value::Vec3([45.0, 45.0, -40.0]));
set(l, "transform/anchor", Value::Vec3([10.0, 10.0, 0.0]));
});
let img = render(&p, cid);
let at = |x: usize, y: usize| img.data[y * 120 + x];
// Multiply over red: half red, no gray.
let c = at(70, 45);
assert!((c[0] - 0.5).abs() < 0.02 && c[1] < 0.02 && c[2] < 0.02 && c[3] > 0.99, "multiplied {c:?}");
// Outside the gray card: plain red.
let r = at(115, 5);
assert!(r[0] > 0.98 && r[1] < 0.02, "{r:?}");
// The nearer blue card hides the Multiply card behind it.
let b = at(40, 45);
assert!(b[2] > 0.98 && b[0] < 0.02, "occluded {b:?}");
// Normal mode for comparison: gray covers the red.
p.comp_mut(cid).unwrap().layers[1].blend_mode = effectcraft_color::BlendMode::Normal;
let n = render(&p, cid).data[45 * 120 + 70];
assert!((n[0] - 0.5).abs() < 0.02 && (n[1] - 0.5).abs() < 0.02, "{n:?}");
}
#[test]
fn track_mattes_in_advanced_3d() {
let (mut p, cid) = project(120, 90);
let white = solid(&mut p, [1.0, 1.0, 1.0], 120, 90);
let small = solid(&mut p, [1.0, 1.0, 1.0], 40, 30);
// Layer order (top first): the matte, then the matted layer.
add(&mut p, cid, LayerSource::Solid { item: white }, |l| l.switches.three_d = true);
add(&mut p, cid, LayerSource::Solid { item: small }, |l| {
l.switches.three_d = true;
l.switches.video = false;
// A 3D matte is seen through the camera.
set(l, "transform/position", Value::Vec3([30.0, 30.0, 0.0]));
set(l, "transform/anchor", Value::Vec3([20.0, 15.0, 0.0]));
});
let matte = p.comp(cid).unwrap().layers[0].id;
p.comp_mut(cid).unwrap().layers[1].track_matte = Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::Alpha });
let img = render(&p, cid);
let inside = img.data[30 * 120 + 30];
let outside = img.data[70 * 120 + 100];
assert!(inside[3] > 0.99 && inside[0] > 0.99, "{inside:?}");
assert!(outside[3] < 0.01, "{outside:?}");
// An inverted alpha matte keeps the outside instead.
p.comp_mut(cid).unwrap().layers[1].track_matte =
Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::AlphaInverted });
let img = render(&p, cid);
assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99);
}
#[test]
fn collapsed_precomps_join_the_advanced_3d_scene() {
// A cube in a nested (Classic 3D) comp, collapsed into an Advanced 3D comp: the cube is
// real geometry lit by the parent's light, not a flat card of the nested comp (which, being
// Classic 3D, draws no meshes at all).
let (mut p, cid) = project(160, 120);
let mut nc = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
nc.renderer = R3::Classic3D;
let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into()));
add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(60.0));
set(l, "geometryOptions/height", Value::Scalar(60.0));
set(l, "geometryOptions/depth", Value::Scalar(60.0));
set(l, "transform/rotationY", Value::Scalar(35.0));
set(l, "transform/rotationX", Value::Scalar(-25.0));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Parallel }, |_| {});
add(&mut p, cid, LayerSource::Comp { item: nid }, |l| {
l.switches.three_d = true;
l.switches.collapse = true;
});
let shades = |img: &crate::Image| {
let mut s: Vec<i32> = img.data.iter().filter(|q| q[3] > 0.99).map(|q| (q[0] * 20.0) as i32).collect();
s.sort();
s.dedup();
s
};
let img = render(&p, cid);
assert!(img.data[60 * 160 + 80][3] > 0.99, "the nested cube covers the centre");
assert!(shades(&img).len() >= 3, "faces lit by the parent's light: {:?}", shades(&img));
// The scene holds the cube's triangles (a card would be two).
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx::new(&p, cid, comp, Tick::ZERO);
let run: Vec<&effectcraft_project::Layer> = comp.layers.iter().filter(|l| l.is_3d() && !l.is_light()).collect();
let s = scene_of(&r, &ctx, &run, (160, 120));
assert!(s.indices.len() / 3 >= 12, "{} triangles", s.indices.len() / 3);
// A 2D collapsed precomp draws its 3D layers through the parent's Advanced 3D renderer too.
p.comp_mut(cid).unwrap().layers[0].switches.three_d = false;
let flat = render(&p, cid);
assert!(flat.data[60 * 160 + 80][3] > 0.99);
assert!(shades(&flat).len() >= 3, "{:?}", shades(&flat));
// Collapse off: the nested comp renders on its own (Classic 3D: no meshes).
p.comp_mut(cid).unwrap().layers[0].switches.collapse = false;
p.comp_mut(cid).unwrap().layers[0].switches.three_d = true;
assert!(render(&p, cid).data.iter().all(|q| q[3] == 0.0));
}
#[test]
fn extruded_strokes_render_as_bevelled_meshes() {
let (mut p, cid) = project(200, 120);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc {
text: "I".into(),
size: 80.0,
fill: [1.0, 1.0, 1.0, 1.0],
apply_fill: true,
stroke: [1.0, 0.0, 0.0, 1.0],
apply_stroke: true,
stroke_width: 8.0,
stroke_over_fill: false,
..Default::default()
};
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(20.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
assert_eq!(meshes.len(), 2, "fill and stroke");
let width = |m: &effectcraft_model::Primitive| {
let (a, b) = m.positions.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(a, b), q| (a.min(q[0]), b.max(q[0])));
b - a
};
let (fill, stroke) = (&meshes[0], &meshes[1]);
assert_eq!(fill.1, [1.0, 1.0, 1.0, 1.0]);
assert_eq!(stroke.1, [1.0, 0.0, 0.0, 1.0]);
// The stroke reaches half its width beyond the fill on each side, and is extruded too.
assert!((width(&stroke.0) - width(&fill.0) - 8.0).abs() < 1.0, "{} vs {}", width(&stroke.0), width(&fill.0));
assert!(stroke.0.positions.iter().any(|q| q[2] > 19.0));
// Fill Over Stroke: the fill's front cap sits in front of the stroke's.
let front = |m: &effectcraft_model::Primitive| m.positions.iter().fold(f32::INFINITY, |a, q| a.min(q[2]));
assert!(front(&fill.0) < front(&stroke.0));
// Rendered (unlit: base colours): a red rim around the white face.
let img = render(&p, cid);
let red = img.data.iter().filter(|q| q[3] > 0.99 && q[0] > 0.9 && q[1] < 0.1).count();
let white = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9).count();
assert!(red > 50 && white > 50, "red {red}, white {white}");
}
#[test]
fn stroke_regions_are_rings() {
// A closed path's stroke covers a ring (the inside stays open), so an extruded stroke is a
// hollow frame around its fill.
let sq = effectcraft_path::rect([100.0, 100.0], [0.0, 0.0], 0.0);
let st = effectcraft_path::StrokeStyle { width: 10.0, ..Default::default() };
let r = crate::shapes::stroke_region(std::slice::from_ref(&sq), &st).unwrap();
let a = effectcraft_path::boolean::filled_area(&r, effectcraft_path::FillRule::NonZero);
assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 20.0, "{a}");
let outlines = effectcraft_model::extrude::group_contours(scene::contours(&r, 0.25));
assert_eq!(outlines.len(), 1);
assert_eq!(outlines[0].holes.len(), 1);
}