Files
resolved/crates/resolved/tests/shadows.rs
T
2026-10-14 20:34:12 -04:00

385 lines
14 KiB
Rust

//! Shadows on targets at lighting L1, on the GPU and on the CPU (lavapipe): where they
//! fall, how wide their penumbra is, and that surfaces do not shadow themselves.
mod common;
use std::io::Cursor;
use std::sync::Arc;
use resolved::gpu::AdapterChoice;
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
use resolved::radiometry::{Radiometry, AU};
use resolved::{
CameraConfig, EarthTexture, Fidelity, LightingTier, LoadOptions, Material, Mesh, Pose,
Renderer, RendererOptions, Scene, Target,
};
const ALBEDO: f64 = 0.5;
const TARGET: Vector3<f64> = Vector3::new(7.0e6, 0.0, 0.0);
/// Straight up from the target: body +X faces the Sun with the identity attitude.
const SUN: Vector3<f64> = Vector3::new(AU, 0.0, 0.0);
/// Half the width of the plate the shadows fall on, in meters.
const PLATE: f32 = 4.0;
/// The Sun's angular diameter seen from 1 AU, as a tangent: 2 x 696,000 km / 1 AU.
const SUN_SPREAD: f64 = 0.0093;
/// Four-cornered faces, each split into two triangles, as one gray mesh.
fn quads(name: &str, faces: &[[[f32; 3]; 4]]) -> Mesh {
let mut s = format!("solid {name}\n");
for f in faces {
for tri in [[f[0], f[1], f[2]], [f[0], f[2], f[3]]] {
s += "facet normal 0 0 0\nouter loop\n";
for [x, y, z] in tri {
s += &format!("vertex {x} {y} {z}\n");
}
s += "endloop\nendfacet\n";
}
}
s += &format!("endsolid {name}\n");
let options = LoadOptions {
default_material: Material::gray("gray", ALBEDO),
..Default::default()
};
Mesh::from_stl(&mut Cursor::new(s), name, &options).unwrap()
}
/// A square in the body YZ plane at height `x`, facing +X.
fn square(x: f32, half: f32) -> [[f32; 3]; 4] {
[
[x, -half, -half],
[x, half, -half],
[x, half, half],
[x, -half, half],
]
}
/// The plate alone.
fn plate() -> Mesh {
quads("plate", &[square(0.0, PLATE)])
}
/// The plate with a 2 m square occluder `height` meters above its center.
fn plate_under(height: f32) -> Mesh {
quads("plate", &[square(0.0, PLATE), square(height, 1.0)])
}
/// A UV sphere of radius 3 m, flat-faceted: 48 segments around, 24 from pole to pole.
fn sphere() -> Mesh {
let (around, down) = (48, 24);
let point = |i: usize, j: usize| {
let lon = i as f32 / around as f32 * std::f32::consts::TAU;
let lat = (j as f32 / down as f32 - 0.5) * std::f32::consts::PI;
[lat.cos() * lon.cos(), lat.cos() * lon.sin(), lat.sin()].map(|c| 3.0 * c)
};
let faces: Vec<_> = (0..around)
.flat_map(|i| (0..down).map(move |j| (i, j)))
.map(|(i, j)| {
[
point(i, j),
point(i + 1, j),
point(i + 1, j + 1),
point(i, j + 1),
]
})
.collect();
quads("sphere", &faces)
}
fn renderer(adapter: &AdapterChoice, lighting: LightingTier) -> Renderer {
let gray = EarthTexture::from_rgba8(1, 1, vec![128, 128, 128, 255]).unwrap();
let options = RendererOptions {
adapter: adapter.clone(),
fidelity: Fidelity {
lighting,
..Default::default()
},
earth_texture: Some(Arc::new(gray)),
read_radiance: true,
..Default::default()
};
Renderer::new(CameraConfig::example(), options).unwrap()
}
/// 30 m from the target along `direction` (target body frame), looking at it.
fn observer(direction: Vector3<f64>, up: Vector3<f64>) -> Pose {
Pose::looking_at(TARGET + 30.0 * direction.normalize(), TARGET, up).unwrap()
}
/// Looking down at the plate 50 degrees from overhead, so an occluder above the plate
/// appears well clear of the shadow it casts.
fn slanted() -> Pose {
let a = 50f64.to_radians();
observer(Vector3::new(a.cos(), 0.0, a.sin()), Vector3::z())
}
/// 10 m from the occluder's shadow edge (body z = 1 m), 40 degrees from overhead,
/// where a pixel spans 1.4 mm of plate across the edge, a seventh of a 1 m occluder's
/// penumbra.
fn close_to_edge() -> Pose {
let a = 40f64.to_radians();
let edge = TARGET + Vector3::z();
let eye = edge + 10.0 * Vector3::new(a.cos(), 0.0, a.sin());
Pose::looking_at(eye, edge, Vector3::z()).unwrap()
}
fn scene(targets: Vec<Target>) -> Scene {
Scene {
earth_attitude: UnitQuaternion::identity(),
sun: SUN,
moon: Vector3::new(0.0, -3.844e8, 0.0),
targets,
}
}
fn at(model: resolved::ModelId, offset: Vector3<f64>) -> Target {
Target {
model,
pose: Pose::new(TARGET + offset, UnitQuaternion::identity()),
}
}
/// Green-channel radiance of a Lambertian surface at `cos_i` to the Sun, at 1 AU.
fn lit(cos_i: f64) -> f64 {
Radiometry::new(&CameraConfig::example()).lambert_radiance(
1,
ALBEDO,
cos_i,
(SUN - TARGET).norm(),
)
}
/// The plate's (y, z), body frame, and the green radiance there, for each pixel of
/// image column 960, the column through the plate's center line. The plate sits at the
/// identity attitude on `TARGET`.
fn column(observer: &Pose, radiance: &[[f32; 4]]) -> Vec<(f64, f64)> {
let intrinsics = CameraConfig::example().intrinsics();
let mut samples: Vec<(f64, f64)> = (0..1080)
.filter_map(|row| {
let ray = observer.attitude * intrinsics.ray(&Vector2::new(960.5, row as f64 + 0.5));
// Where the ray meets the plate's plane, body x = 0.
let t = (TARGET.x - observer.position.x) / ray.x;
let p = observer.position + t * ray - TARGET;
(p.y.abs() < PLATE as f64 && p.z.abs() < PLATE as f64)
.then(|| (p.z, radiance[row * 1920 + 960][1] as f64))
})
.collect();
samples.sort_by(|a, b| a.0.total_cmp(&b.0));
samples
}
/// Where, going up through `samples` from `from`, the radiance first reaches `level`,
/// interpolated between pixels.
fn crossing(samples: &[(f64, f64)], from: f64, level: f64) -> f64 {
let i = samples
.iter()
.position(|&(z, r)| z > from && r >= level)
.expect("never reaches the level");
let ((z0, r0), (z1, r1)) = (samples[i - 1], samples[i]);
z0 + (level - r0) / (r1 - r0) * (z1 - z0)
}
#[test]
fn occluder_shadows_the_plate_below_it() {
let _devices = common::device_lock();
let full = lit(1.0);
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut renderer = renderer(&adapter, LightingTier::L1);
let name = renderer.adapter_info().name.clone();
let model = renderer.load_model(&plate_under(2.0)).unwrap();
let frame = renderer
.submit(&scene(vec![at(model, Vector3::zeros())]), &slanted())
.unwrap()
.wait()
.unwrap();
let radiance = frame.radiance.unwrap();
common::save("shadow-occluder", renderer.adapter_info(), &radiance);
let samples = column(&slanted(), &radiance);
for &(z, r) in &samples {
// Under the occluder, 2 cm clear of the penumbra: no direct sunlight at all.
if z.abs() < 0.97 {
assert_eq!(r, 0.0, "{name}: z = {z}");
}
// Beyond it, and short of where the occluder hides the plate from the
// camera: plain Lambert.
if z > 1.03 {
assert!(
(r - full).abs() < 1e-4 * full,
"{name}: z = {z}: {r} vs {full}"
);
}
}
// The edge is the occluder's edge, z = 1 m, to within a texel of the 4096 map
// across the plate's 11.3 m bounding sphere (2.8 mm).
let edge = crossing(&samples, 0.0, 0.5 * full);
assert!((edge - 1.0).abs() < 2.8e-3, "{name}: edge at {edge}");
}
}
#[test]
fn penumbra_widens_with_occluder_height() {
let _devices = common::device_lock();
let full = lit(1.0);
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut renderer = renderer(&adapter, LightingTier::L1);
let name = renderer.adapter_info().name.clone();
for height in [1.0, 2.0, 3.0] {
let model = renderer.load_model(&plate_under(height)).unwrap();
let frame = renderer
.submit(&scene(vec![at(model, Vector3::zeros())]), &close_to_edge())
.unwrap()
.wait()
.unwrap();
let samples = column(&close_to_edge(), &frame.radiance.unwrap());
// The Sun's disk, seen past a straight edge, uncovers linearly in the
// filter this models, so 10 % to 90 % is 0.8 of the full width.
let width =
(crossing(&samples, 0.0, 0.9 * full) - crossing(&samples, 0.0, 0.1 * full)) / 0.8;
let want = height as f64 * SUN_SPREAD;
assert!(
(width - want).abs() < 0.05 * want,
"{name}: occluder {height} m up: penumbra {width} m, want {want} m"
);
}
}
}
/// Pixels lit at L0 but darker at L1, among those at least 10 % of `full`: a surface
/// that nothing shadows must not darken (shadow acne).
fn darkened(l0: &[[f32; 4]], l1: &[[f32; 4]], full: f64) -> usize {
l0.iter()
.zip(l1)
.filter(|(a, b)| a[1] as f64 > 0.1 * full && b[1] < 0.999 * a[1])
.count()
}
#[test]
fn unshadowed_surfaces_do_not_shadow_themselves() {
let _devices = common::device_lock();
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut renderers =
[LightingTier::L0, LightingTier::L1].map(|lighting| renderer(&adapter, lighting));
let name = renderers[1].adapter_info().name.clone();
// The plate tipped 80 and 87 degrees from the Sun, viewed face on, and a sphere
// of flat facets, whose terminator crosses facets at every angle to the Sun,
// seen against space so only the target can differ.
let a = |deg: f64| deg.to_radians();
let tipped = |deg: f64| {
let attitude = UnitQuaternion::from_axis_angle(&Vector3::y_axis(), -a(deg));
let normal = attitude * Vector3::x();
(
plate(),
attitude,
observer(normal, Vector3::y()),
format!("plate at {deg}"),
)
};
let cases = [
tipped(80.0),
tipped(87.0),
(
sphere(),
UnitQuaternion::identity(),
observer(Vector3::new(-0.3, 1.0, 0.3), Vector3::z()),
"sphere".to_string(),
),
];
for (mesh, attitude, observer, case) in cases {
let frames = renderers.each_mut().map(|renderer| {
let model = renderer.load_model(&mesh).unwrap();
let target = Target {
model,
pose: Pose::new(TARGET, attitude),
};
renderer
.submit(&scene(vec![target]), &observer)
.unwrap()
.wait()
.unwrap()
.radiance
.unwrap()
});
let file = format!("shadow-acne-{}", case.replace(' ', "-"));
common::save(&file, renderers[1].adapter_info(), &frames[1]);
let count = darkened(&frames[0], &frames[1], lit(1.0));
assert_eq!(count, 0, "{name}: {case}: {count} pixels darkened");
}
}
}
#[test]
fn sunward_target_shadows_and_side_target_does_not() {
let _devices = common::device_lock();
let full = lit(1.0);
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut renderer = renderer(&adapter, LightingTier::L1);
let name = renderer.adapter_info().name.clone();
let plate = renderer.load_model(&plate()).unwrap();
let panel = renderer
.load_model(&quads("panel", &[square(0.0, 1.0)]))
.unwrap();
// A 2 m panel tipped 45 degrees, so its +Z half is the nearer the Sun; that
// half's shadow covers plate z from 0 to 0.71 m.
let tipped = |offset: Vector3<f64>| Target {
model: panel,
pose: Pose::new(
TARGET + offset,
UnitQuaternion::from_axis_angle(&Vector3::y_axis(), 45f64.to_radians()),
),
};
let mut at_z = |targets, want: f64| {
let frame = renderer
.submit(&scene(targets), &slanted())
.unwrap()
.wait()
.unwrap();
let samples = column(&slanted(), &frame.radiance.unwrap());
let (_, r) = *samples
.iter()
.min_by(|a, b| (a.0 - want).abs().total_cmp(&(b.0 - want).abs()))
.unwrap();
r
};
// The panel 6 m sunward of the plate's center, listed before or after the
// plate, shadows it on both sides of center.
let sunward = Vector3::new(6.0, 0.0, 0.0);
for z in [-0.4, 0.4] {
let before = at_z(vec![tipped(sunward), at(plate, Vector3::zeros())], z);
let after = at_z(vec![at(plate, Vector3::zeros()), tipped(sunward)], z);
assert_eq!((before, after), (0.0, 0.0), "{name}: z = {z}");
}
// The same panel 12 m to the side does not.
let side = at_z(
vec![
at(plate, Vector3::zeros()),
tipped(Vector3::new(0.0, -12.0, 0.0)),
],
0.0,
);
assert!(
(side - full).abs() < 1e-4 * full,
"{name}: {side} vs {full}"
);
}
}
#[test]
fn lighting_l0_draws_no_shadows() {
let _devices = common::device_lock();
let full = lit(1.0);
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut renderer = renderer(&adapter, LightingTier::L0);
let name = renderer.adapter_info().name.clone();
let model = renderer.load_model(&plate_under(2.0)).unwrap();
let frame = renderer
.submit(&scene(vec![at(model, Vector3::zeros())]), &slanted())
.unwrap()
.wait()
.unwrap();
for (z, r) in column(&slanted(), &frame.radiance.unwrap()) {
if z > -1.3 {
assert!((r - full).abs() < 1e-4 * full, "{name}: z = {z}: {r}");
}
}
}
}