mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
1050 lines
53 KiB
Rust
1050 lines
53 KiB
Rust
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(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 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(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 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(shade::BrdfSurface { base: [1.0; 3], metallic: 0.0, roughness: 1.0, specular: 1.0, diffuse: 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 accepts_shadows_only_draws_just_the_shadow() {
|
||
// #1691: a shadow catcher (card or plane primitive, "Accepts Shadows: Only") in front of a
|
||
// red backdrop card, an invisible sphere casting onto it, a point light behind the camera.
|
||
let (mut p, cid) = project(200, 150);
|
||
let red = p.add_item(
|
||
"R",
|
||
Default::default(),
|
||
None,
|
||
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 0.0, 0.0], width: 400, height: 400, pixel_aspect: 1.0 }),
|
||
);
|
||
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
|
||
l.switches.three_d = true;
|
||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 300.0]));
|
||
set(l, "materialOptions/acceptsShadows", Value::Enum(0));
|
||
});
|
||
let white = p.add_item(
|
||
"W",
|
||
Default::default(),
|
||
None,
|
||
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 400, height: 400, pixel_aspect: 1.0 }),
|
||
);
|
||
add(&mut p, cid, LayerSource::Solid { item: white }, |l| {
|
||
l.switches.three_d = true;
|
||
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
|
||
set(l, "materialOptions/acceptsShadows", Value::Enum(2));
|
||
});
|
||
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 check = |img: &crate::Image, what: &str| {
|
||
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 60]);
|
||
// Unshadowed: the catcher is see-through, the lit red card behind it shows.
|
||
assert!(side[0] > 0.5 && side[1] < side[0] * 0.25 && side[3] > 0.99, "{what}: side {side:?}");
|
||
// Shadowed: black over the red card.
|
||
assert!(mid[0] < 0.02 && mid[3] > 0.99, "{what}: shadow {mid:?} vs side {side:?}");
|
||
};
|
||
check(&render(&p, cid), "card");
|
||
// Nothing behind the catcher: only the shadow is opaque.
|
||
let solo = |p: &Project| {
|
||
let mut q = p.clone();
|
||
q.comp_mut(cid).unwrap().layers.pop();
|
||
let img = render(&q, cid);
|
||
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 60]);
|
||
assert!(side[3] < 1e-3, "unshadowed catcher is transparent: {side:?}");
|
||
assert!(mid[3] > 0.5 && mid[0] < 1e-3, "shadow is black: {mid:?}");
|
||
};
|
||
solo(&p);
|
||
// The same with a plane primitive as the catcher.
|
||
let layers = &mut p.comp_mut(cid).unwrap().layers;
|
||
layers.remove(2);
|
||
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/acceptsShadows", Value::Enum(2));
|
||
});
|
||
check(&render(&p, cid), "plane");
|
||
solo(&p);
|
||
}
|
||
|
||
#[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, ¶ms);
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn shape_surface_items_colour_the_extruded_surfaces() {
|
||
let (mut p, cid) = project(200, 200);
|
||
add(&mut p, cid, LayerSource::Shape, |l| {
|
||
l.switches.three_d = true;
|
||
let mut next = 10_000u64;
|
||
let ids = &mut build::Ids(&mut next);
|
||
let surface = |ids: &mut build::Ids, m: &str, c: [f64; 4]| {
|
||
let mut g = build::shape_surface(ids, m).unwrap();
|
||
g.children.push(build::shape_surface_prop(ids, "color").unwrap().into());
|
||
if let Some(effectcraft_project::Node::Prop(pr)) = g.children.last_mut() {
|
||
pr.value = Value::Color(c);
|
||
}
|
||
g
|
||
};
|
||
// A white square whose group colours its sides green; the layer's Contents colour every
|
||
// front blue and every side red, but the group's own Side wins.
|
||
let rect = build::shape_rect(ids, [80.0, 80.0], [0.0, 0.0], 0.0);
|
||
let fill = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
|
||
let side = surface(ids, "side", [0.0, 1.0, 0.0, 1.0]);
|
||
let group = build::shape_group(ids, "Group 1", vec![rect, fill, side]);
|
||
let front = surface(ids, "front", [0.0, 0.0, 1.0, 1.0]);
|
||
let outer_side = surface(ids, "side", [1.0, 0.0, 0.0, 1.0]);
|
||
let contents = l.props.sub_mut("contents").unwrap();
|
||
contents.children.extend([group.into(), front.into(), outer_side.into()]);
|
||
l.props.children.push(build::extrusion_geometry_options(ids).into());
|
||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
|
||
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.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, ¶ms);
|
||
// No bevel: Front, Side and Back (the Back keeps the fill's white).
|
||
let colours: Vec<[f32; 4]> = meshes.iter().map(|m| m.1).collect();
|
||
assert_eq!(colours, [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]]);
|
||
assert!(meshes[0].0.normals.iter().all(|n| n[2] < -0.99), "front cap");
|
||
assert!(meshes[1].0.normals.iter().all(|n| n[2].abs() < 1e-6), "walls");
|
||
// Rendered (unlit: base colours) at an angle: the blue face and the green side both show.
|
||
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/rotationY").unwrap().value = Value::Scalar(50.0);
|
||
let img = render(&p, cid);
|
||
let blue = img.data.iter().filter(|q| q[3] > 0.99 && q[2] > 0.9 && q[0] < 0.1 && q[1] < 0.1).count();
|
||
let green = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9 && q[0] < 0.1 && q[2] < 0.1).count();
|
||
assert!(blue > 200 && green > 50, "blue {blue}, green {green}");
|
||
}
|
||
|
||
#[test]
|
||
fn text_animators_colour_the_extruded_surfaces() {
|
||
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;
|
||
let ids = &mut build::Ids(&mut next);
|
||
let props = ["sideColor", "bevelColor"].iter().flat_map(|k| build::text_anim_props(ids, k, false)).collect();
|
||
let anim = build::text_animator(ids, "Animator 1", props);
|
||
l.props.group_mut("text/animators").unwrap().children.push(anim.into());
|
||
set(l, "text/animators/#1/properties/sideColor", Value::Color([0.0, 1.0, 0.0, 1.0]));
|
||
// Half the selection: the bevel colour blends half way over the fill.
|
||
set(l, "text/animators/#1/selectors/#1/advanced/amount", Value::Scalar(50.0));
|
||
l.props.children.push(build::extrusion_geometry_options(ids).into());
|
||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.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, ¶ms);
|
||
// Two characters × (Front, Bevel, Side, Back).
|
||
assert_eq!(meshes.len(), 8);
|
||
for m in meshes.chunks(4) {
|
||
assert_eq!(m[0].1, [1.0, 1.0, 1.0, 1.0], "front keeps the fill");
|
||
assert_eq!(m[1].1, [1.0, 0.5, 0.5, 1.0], "bevel: red half way over white");
|
||
assert_eq!(m[2].1, [0.5, 1.0, 0.5, 1.0], "side: green half way over white");
|
||
assert_eq!(m[3].1, [1.0, 1.0, 1.0, 1.0], "back keeps the fill");
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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, ¶ms);
|
||
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);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- shadow quality
|
||
|
||
use super::raster::{MAX_SHADOW_TEXELS, shadow_map_size, shadow_point_face_size, shadow_sizes};
|
||
use super::scene::ShadowMap;
|
||
|
||
/// Geometric normal of a receiver squarely facing a `flat_map` light (which looks along +z).
|
||
const FACING: [f32; 3] = [0.0, 0.0, -1.0];
|
||
|
||
/// Identity-view orthographic map: map coords are world x, y × `focal` offset by
|
||
/// `size/2` (`focal` texels per world unit).
|
||
fn flat_map(size: u32, focal: f32, radius: f32) -> ShadowMap {
|
||
ShadowMap { 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]], focal, ortho: true, size, offset: 0, bias: 1.5, radius }
|
||
}
|
||
|
||
/// Wall: texel columns ≥ a quarter of the map at depth 10, the rest unoccluded (1000).
|
||
/// Its edge sits at world x = −size / (4·focal).
|
||
fn wall_map(size: u32, focal: f32, radius: f32) -> (ShadowMap, Vec<f32>) {
|
||
let texels: Vec<f32> = (0..size * size).map(|i| if i % size >= size / 4 { 10.0 } else { 1000.0 }).collect();
|
||
(flat_map(size, focal, radius), texels)
|
||
}
|
||
|
||
/// Luma of a render of `cid` with its top layer (a light) set to cast shadows or not,
|
||
/// at the given Shadow Diffusion.
|
||
fn render_light_luma(p: &mut Project, cid: ItemId, casting: bool, diffusion: f64) -> Vec<f32> {
|
||
let comp = p.comp_mut(cid).unwrap();
|
||
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(casting));
|
||
set(&mut comp.layers[0], "lightOptions/shadowDiffusion", Value::Scalar(diffusion));
|
||
render(p, cid).data.iter().map(|q| (q[0] + q[1] + q[2]) / 3.0).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn shadow_map_size_tracks_raster_resolution() {
|
||
assert_eq!(shadow_map_size(640, 360, false), 1024);
|
||
assert_eq!(shadow_map_size(1920, 1080, false), 2048);
|
||
assert_eq!(shadow_map_size(3840, 2160, false), 4096);
|
||
// Clamped to the pow2 range: small or absurd rasters stay inside 512..4096.
|
||
assert_eq!(shadow_map_size(64, 64, false), 512);
|
||
assert_eq!(shadow_map_size(0, 0, false), 512);
|
||
assert_eq!(shadow_map_size(u32::MAX, u32::MAX, false), 4096);
|
||
assert_eq!(shadow_map_size(7, 100000, false), 4096);
|
||
// The fast-preview (draft) budget stays at 512 regardless, so interactivity is
|
||
// untouched by the quality path's new sizing.
|
||
assert_eq!(shadow_map_size(3840, 2160, true), 512);
|
||
assert_eq!(shadow_map_size(0, 0, true), 512);
|
||
}
|
||
|
||
#[test]
|
||
fn shadow_point_faces_are_capped() {
|
||
// Cube faces take the full map size up to 2048 (one 2048 cube ≈ 25 M f32 texels).
|
||
assert_eq!(shadow_point_face_size(4096, false), 2048);
|
||
assert_eq!(shadow_point_face_size(2048, false), 2048);
|
||
assert_eq!(shadow_point_face_size(1024, false), 1024);
|
||
assert_eq!(shadow_point_face_size(512, false), 512);
|
||
// Draft keeps the historical half-size face.
|
||
assert_eq!(shadow_point_face_size(512, true), 256);
|
||
}
|
||
|
||
fn budget_texels(sizes: &[Option<u32>], kinds: &[u32]) -> u64 {
|
||
sizes.iter().zip(kinds).map(|(s, &k)| s.map(|z| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).unwrap_or(0)).sum()
|
||
}
|
||
|
||
#[test]
|
||
fn shadow_sizes_stay_within_the_scene_budget() {
|
||
// Within budget a light gets its full size: one 4K point cube keeps 2048 faces.
|
||
assert_eq!(shadow_sizes(4096, 2160, false, &[2]), vec![Some(2048)]);
|
||
// Several cubes overflow: deterministically halved (largest first, earliest light
|
||
// on ties) until inside: three or four settle at 1024, five push two to 512.
|
||
assert_eq!(shadow_sizes(4096, 2160, false, &[2; 3]), vec![Some(1024); 3]);
|
||
assert_eq!(shadow_sizes(4096, 2160, false, &[2; 4]), vec![Some(1024); 4]);
|
||
let sizes = shadow_sizes(4096, 2160, false, &[2; 5]);
|
||
assert_eq!(sizes, vec![Some(512), Some(512), Some(1024), Some(1024), Some(1024)]);
|
||
assert!(budget_texels(&sizes, &[2; 5]) <= MAX_SHADOW_TEXELS);
|
||
// A hostile light count can never break the cap; sizes only shrink or drop to None.
|
||
let many = vec![2u32; 1000];
|
||
let sizes = shadow_sizes(3840, 2160, false, &many);
|
||
assert_eq!(sizes.len(), 1000);
|
||
assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS);
|
||
assert!(sizes.iter().flatten().all(|&z| (128..=2048).contains(&z)));
|
||
// Deterministic: same inputs, same plan.
|
||
assert_eq!(sizes, shadow_sizes(3840, 2160, false, &many));
|
||
// Non-point lights and a modest scene keep full quality sizes.
|
||
assert_eq!(shadow_sizes(1920, 1080, false, &[1; 2]), vec![Some(2048), Some(2048)]);
|
||
// A light that casts nothing costs nothing: it stays None and leaves its
|
||
// full-size budget to the lights that do cast.
|
||
assert_eq!(shadow_sizes(640, 360, false, &[0, 1]), vec![None, Some(1024)]);
|
||
assert!(shadow_sizes(640, 360, false, &[0; 500]).iter().all(Option::is_none));
|
||
}
|
||
|
||
#[test]
|
||
fn diffusion_does_not_lift_a_deep_shadow() {
|
||
// Shadow Diffusion may widen the penumbra but must not scale the depth bias: a
|
||
// point deep inside a shadow stays fully shadowed at any diffusion.
|
||
let (m, texels) = wall_map(64, 1.0, 1.0);
|
||
let deep = [0.0f32, 0.0, 13.5]; // u = 32: ±16 texels of margin around the edge at 16; z − bias 12 > 10
|
||
let hard = shade::shadow_factor(&m, &texels, deep, FACING, 1.0);
|
||
assert!(hard < 0.01, "deep shadow at radius 1: {hard}");
|
||
let wide = shade::shadow_factor(&flat_map(64, 1.0, 25.0), &texels, deep, FACING, 1.0);
|
||
assert!(wide < 0.01, "deep shadow at radius 25 (bias must not grow with radius): {wide}");
|
||
}
|
||
|
||
#[test]
|
||
fn pcf_varies_within_a_texel() {
|
||
// Nearest-texel PCF snaps the edge to texel boundaries: two receivers 1/20 texel
|
||
// apart get bit-identical factors, which is what stair-steps straight edges.
|
||
// The filtering must produce coverage that varies within a single texel.
|
||
let (m, texels) = wall_map(64, 1.0, 2.0);
|
||
let mut pairs = 0;
|
||
let mut varying = 0;
|
||
let mut u = -17.0f32;
|
||
while u < -15.3 {
|
||
let a = shade::shadow_factor(&m, &texels, [u, 0.0, 13.5], FACING, 1.0);
|
||
let b = shade::shadow_factor(&m, &texels, [u + 0.05, 0.0, 13.5], FACING, 1.0);
|
||
pairs += 1;
|
||
if (a - b).abs() > 1e-6 {
|
||
varying += 1;
|
||
}
|
||
assert!((0.0..=1.0).contains(&a) && (0.0..=1.0).contains(&b), "{a} {b}");
|
||
u += 0.05;
|
||
}
|
||
assert!(pairs >= 30);
|
||
assert_eq!(varying, pairs, "coverage varies inside every texel along the edge");
|
||
}
|
||
|
||
#[test]
|
||
fn wide_diffusion_keeps_shadow_depth_and_contact() {
|
||
// Scene: camera-facing floor, sphere casting, point light behind the camera and
|
||
// off to the upper right so most of the shadow lands beside the sphere, not
|
||
// behind it. Raising Shadow Diffusion must neither wash the shadow out (a
|
||
// diffusion-scaled bias leaks light into it) nor move where it touches.
|
||
let (mut p, cid) = project(200, 150);
|
||
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(1));
|
||
});
|
||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
|
||
set(l, "transform/position", Value::Vec3([180.0, 20.0, -300.0]));
|
||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||
});
|
||
let noshadow = render_light_luma(&mut p, cid, false, 0.0);
|
||
let d0 = render_light_luma(&mut p, cid, true, 0.0);
|
||
let d40 = render_light_luma(&mut p, cid, true, 40.0);
|
||
// Per-column darkening vs the shadowless render: depth (max darkening) and the
|
||
// deepest dark row (contact edge). Diffusion may blur the edge but must keep the
|
||
// shadow dark (no bias leak) and keep it where it touches (no lift).
|
||
let dep = |img: &[f32], x: usize| -> f32 { (20..140).map(|y| noshadow[y * 200 + x] - img[y * 200 + x]).fold(0.0f32, f32::max) };
|
||
let cols: Vec<usize> = (30..170).filter(|&x| dep(&d0, x) > 0.1).collect();
|
||
assert!(cols.len() > 30, "shadow covers many columns: {}", cols.len());
|
||
// Core depth: the 90th-percentile darkening over the d0 shadow's pixels. A blur
|
||
// only softens the rim and leaves the core dark; a bias leak lightens all of it.
|
||
let shadow_px: Vec<usize> = (20..140).flat_map(|y| (30..170).map(move |x| y * 200 + x)).filter(|&i| noshadow[i] - d0[i] > 0.1).collect();
|
||
let core = |img: &[f32]| -> f32 {
|
||
let mut v: Vec<f32> = shadow_px.iter().map(|&i| noshadow[i] - img[i]).collect();
|
||
v.sort_by(f32::total_cmp);
|
||
v[v.len() * 9 / 10]
|
||
};
|
||
let (core0, core40) = (core(&d0), core(&d40));
|
||
// Width of the shadow across its centre row: a diffusion-scaled bias shrinks the
|
||
// shadow (light leaks under its edges), while a correct filter keeps the extent
|
||
// and only softens the boundary (threshold crossings move at most with the
|
||
// penumbra, not with the bias).
|
||
let width = |img: &[f32]| -> f32 {
|
||
(30..130)
|
||
.map(|y| {
|
||
let dark: Vec<bool> = (0..200).map(|x| noshadow[y * 200 + x] - img[y * 200 + x] > 0.06).collect();
|
||
match (dark.iter().position(|&d| d), dark.iter().rposition(|&d| d)) {
|
||
(Some(l), Some(r)) if r > l => (r - l) as f32,
|
||
_ => 0.0,
|
||
}
|
||
})
|
||
.fold(0.0f32, f32::max)
|
||
};
|
||
let (w0, w40) = (width(&d0), width(&d40));
|
||
let wratio = w40 / w0;
|
||
assert!(w0 > 20.0, "shadow is wide enough to judge: {w0}");
|
||
assert!(core40 >= 0.9 * core0, "diffusion 40 keeps the shadow core dark: {core40:.4} vs {core0:.4}");
|
||
assert!(wratio >= 0.8, "diffusion 40 does not shrink the shadow: width {w40} vs {w0}");
|
||
}
|
||
|
||
#[test]
|
||
fn penumbra_dithers_along_a_straight_edge() {
|
||
// Receivers at the same distance from a straight edge, one texel apart along it.
|
||
// A fixed tap grid gives each the same coverage, so the penumbra repeats texel by
|
||
// texel (stair-steps and bands); the jitter, keyed below texel size, must vary it.
|
||
let (m, texels) = wall_map(64, 1.0, 6.0);
|
||
let distinct = |vs: &[f32]| -> usize {
|
||
let mut bits: Vec<u32> = vs.iter().map(|v| v.to_bits()).collect();
|
||
bits.sort_unstable();
|
||
bits.dedup();
|
||
bits.len()
|
||
};
|
||
let along: Vec<f32> = (0..32).map(|k| shade::shadow_factor(&m, &texels, [-16.0, -16.0 + k as f32, 13.5], FACING, 1.0)).collect();
|
||
assert!(distinct(&along) >= 16, "coverage along the edge takes {} distinct values", distinct(&along));
|
||
// And finer than a texel: where one texel spans several output pixels (a grazing
|
||
// floor), dither keyed per texel would show as texel-sized blocks of grain.
|
||
let within: Vec<f32> = (0..8).map(|k| shade::shadow_factor(&m, &texels, [-16.0, 0.0625 + k as f32 * 0.125, 13.5], FACING, 1.0)).collect();
|
||
assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within));
|
||
}
|
||
|
||
#[test]
|
||
fn penumbra_width_is_independent_of_map_resolution() {
|
||
// One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and
|
||
// a 256-texel map at 4. Shadow Diffusion sets the penumbra in output pixels (world
|
||
// units here), so a finer map must not narrow it.
|
||
let width = |size: u32, focal: f32| -> f32 {
|
||
let (m, texels) = wall_map(size, focal, 8.0);
|
||
let xs: Vec<f32> = (0..400).map(|k| -30.0 + k as f32 * 0.07).collect();
|
||
let f: Vec<f32> = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, 0.0, 13.5], FACING, 1.0)).collect();
|
||
let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]);
|
||
first_below(0.1) - first_below(0.9)
|
||
};
|
||
let (w64, w256) = (width(64, 1.0), width(256, 4.0));
|
||
assert!(w64 > 4.0 && w64 < 12.0, "penumbra spans about the requested 8 units: {w64}");
|
||
assert!((w256 / w64 - 1.0).abs() < 0.25, "same penumbra at any map resolution: {w64} vs {w256}");
|
||
}
|
||
|
||
#[test]
|
||
fn tilted_receivers_do_not_shadow_themselves() {
|
||
// A receiver plane z = 100 + 4x (76° from the light, n·l ≈ 0.24), each texel holding
|
||
// its depth half a texel off-centre the way a rasteriser can. Wide taps must compare
|
||
// against the plane rather than the receiver point, and the slope-scaled bias must
|
||
// absorb the half-texel error, at every diffusion.
|
||
let (size, g) = (128u32, 4.0f32);
|
||
let c = size as f32 * 0.5;
|
||
let texels: Vec<f32> = (0..size * size).map(|i| 100.0 + g * ((i % size) as f32 - c)).collect();
|
||
let len = (1.0 + g * g).sqrt();
|
||
let (ng, n_l) = ([g / len, 0.0, -1.0 / len], 1.0 / len);
|
||
for radius in [1.0f32, 6.0, 25.0] {
|
||
let m = flat_map(size, 1.0, radius);
|
||
for k in 0..40 {
|
||
let x = -20.0 + k as f32 * 0.97;
|
||
let f = shade::shadow_factor(&m, &texels, [x, 3.3, 100.0 + g * x], ng, n_l);
|
||
assert!(f > 0.99, "radius {radius}, x {x}: lit tilted receiver shadowed itself ({f})");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn lit_casters_do_not_shadow_themselves() {
|
||
// A sphere and a turned cube that cast shadows, lit by each light kind: the lit
|
||
// surface must render as bright as with shadows off at any Shadow Diffusion (a
|
||
// wide kernel on a surface tilted to the light must not see its own depth).
|
||
// 640×360: faces then span enough texels that near-grazing ones exercise the
|
||
// receiver plane's slope limit.
|
||
for (prim, rot_y) in [(PrimitiveKind::Sphere, 0.0), (PrimitiveKind::Cube, 35.0)] {
|
||
for kind in [LightKind::Point, LightKind::Spot, LightKind::Parallel] {
|
||
let (mut p, cid) = project(640, 360);
|
||
add(&mut p, cid, LayerSource::Primitive { kind: prim }, |l| {
|
||
set(l, "transform/position", Value::Vec3([320.0, 180.0, 100.0]));
|
||
if prim == PrimitiveKind::Sphere {
|
||
set(l, "geometryOptions/radius", Value::Scalar(120.0));
|
||
} else {
|
||
for k in ["width", "height", "depth"] {
|
||
set(l, &format!("geometryOptions/{k}"), Value::Scalar(216.0));
|
||
}
|
||
set(l, "transform/rotationX", Value::Scalar(-25.0));
|
||
}
|
||
set(l, "transform/rotationY", Value::Scalar(rot_y));
|
||
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
|
||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||
});
|
||
add(&mut p, cid, LayerSource::Light { kind }, |l| {
|
||
set(l, "transform/position", Value::Vec3([-280.0, -108.0, -600.0]));
|
||
if let Some(poi) = l.props.prop_mut("transform/pointOfInterest") {
|
||
poi.value = Value::Vec3([320.0, 180.0, 100.0]);
|
||
}
|
||
});
|
||
let noshadow = render_light_luma(&mut p, cid, false, 0.0);
|
||
let lit: Vec<usize> = (0..noshadow.len()).filter(|&i| noshadow[i] > 0.15).collect();
|
||
assert!(lit.len() > 10_000, "{prim:?} {kind:?}: lit area {}", lit.len());
|
||
for diffusion in [0.0, 20.0, 48.0] {
|
||
let img = render_light_luma(&mut p, cid, true, diffusion);
|
||
let dark = lit.iter().filter(|&&i| img[i] < 0.9 * noshadow[i]).count() as f32 / lit.len() as f32;
|
||
assert!(dark <= 0.02, "{prim:?} {kind:?} diffusion {diffusion}: {:.1} % of the lit surface self-shadowed", dark * 100.0);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn shadow_sizes_match_the_halve_largest_rule_and_scale_to_many_lights() {
|
||
// The specification `shadow_sizes` implements, step by step: halve the largest map
|
||
// above its floor (earliest light on ties) until the scene fits, then drop maps
|
||
// from the last light.
|
||
let spec = |w: u32, h: u32, draft: bool, kinds: &[u32]| -> Vec<Option<u32>> {
|
||
let base = shadow_map_size(w, h, draft);
|
||
let mut sizes: Vec<u32> = kinds
|
||
.iter()
|
||
.map(|&k| {
|
||
if k == 0 {
|
||
0
|
||
} else if k == 2 {
|
||
shadow_point_face_size(base, draft)
|
||
} else {
|
||
base
|
||
}
|
||
})
|
||
.collect();
|
||
let total = |s: &[u32]| -> u64 { s.iter().zip(kinds).map(|(&z, &k)| (z as u64) * (z as u64) * if k == 2 { 6 } else { 1 }).sum() };
|
||
while total(&sizes) > MAX_SHADOW_TEXELS {
|
||
let pick = (0..sizes.len()).filter(|&i| sizes[i] > if kinds[i] == 2 { 128 } else { 512 }).max_by_key(|&i| (sizes[i], std::cmp::Reverse(i)));
|
||
match pick {
|
||
Some(i) => sizes[i] /= 2,
|
||
None => match sizes.iter().rposition(|&z| z > 0) {
|
||
Some(i) => sizes[i] = 0,
|
||
None => break,
|
||
},
|
||
}
|
||
}
|
||
sizes.into_iter().map(|z| (z > 0).then_some(z)).collect()
|
||
};
|
||
let mut seed = 0x2545_F491u32;
|
||
for case in 0..300 {
|
||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||
let n = (seed >> 8) as usize % 40;
|
||
let kinds: Vec<u32> = (0..n)
|
||
.map(|_| {
|
||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||
(seed >> 16) % 3
|
||
})
|
||
.collect();
|
||
let (w, h, draft) = ([640, 1920, 3840, 8000][case % 4], [360, 1080, 2160, 100][case % 4], case % 7 == 0);
|
||
assert_eq!(shadow_sizes(w, h, draft, &kinds), spec(w, h, draft, &kinds), "case {case}: {kinds:?}");
|
||
}
|
||
// A hostile light count stays fast (n log n) and inside the budget.
|
||
let many = vec![2u32; 100_000];
|
||
let sizes = shadow_sizes(3840, 2160, false, &many);
|
||
assert!(budget_texels(&sizes, &many) <= MAX_SHADOW_TEXELS);
|
||
}
|
||
|
||
#[test]
|
||
fn wide_penumbrae_are_smooth() {
|
||
// Coverage at a fixed distance across a wide penumbra, sampled along a straight
|
||
// edge, should be one smooth value plus fine dither. A 5×5 grid stretched over a
|
||
// 20-texel reach leaves gaps the jitter turns into coarse grain (σ ≈ 0.04, ten
|
||
// 8-bit levels); the grid grows with the reach to keep it below 0.02.
|
||
let (m, texels) = wall_map(256, 1.0, 20.0);
|
||
for off in [-5.0f32, 0.0, 5.0] {
|
||
let vs: Vec<f32> = (0..200).map(|k| shade::shadow_factor(&m, &texels, [-64.0 + off, -100.0 + k as f32, 13.5], FACING, 1.0)).collect();
|
||
let mean = vs.iter().sum::<f32>() / vs.len() as f32;
|
||
let sd = (vs.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / vs.len() as f32).sqrt();
|
||
assert!(sd < 0.02, "offset {off}: penumbra grain σ = {sd:.4}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn hard_edges_ramp_over_two_texels() {
|
||
// At Shadow Diffusion 0 the edge still ramps across about two texels (10–90 %):
|
||
// where one texel spans several output pixels (a floor grazing a point light) a
|
||
// one-texel ramp leaves the binary compare's texel steps visible as a sawtooth.
|
||
let (m, texels) = wall_map(64, 1.0, 1.0);
|
||
for v0 in [0.0f32, 0.3, 7.6] {
|
||
let xs: Vec<f32> = (0..400).map(|k| -20.0 + k as f32 * 0.02).collect();
|
||
let f: Vec<f32> = xs.iter().map(|&x| shade::shadow_factor(&m, &texels, [x, v0, 13.5], FACING, 1.0)).collect();
|
||
let first_below = |t: f32| f.iter().position(|&v| v < t).map_or(f32::NAN, |i| xs[i]);
|
||
let w = first_below(0.1) - first_below(0.9);
|
||
assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels");
|
||
}
|
||
}
|