385 lines
14 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|
|
}
|