Sensor/detector DNs

This commit is contained in:
connor committed 2026-10-14 17:05:21 -04:00
1 parent da1d41d7f3
commit 32cc17a622
10 files changed
+532 -56

No files matched your search

+31
View File
@@ -33,6 +33,24 @@ pub struct Detector {
pub adc_bits: u32,
}
impl Detector {
/// The largest value the ADC can output.
pub fn dn_max(&self) -> u32 {
(1 << self.adc_bits) - 1
}
/// The DN a pixel reads after collecting `electrons`, with no noise: the charge
/// saturates at the full well, is divided by the gain, offset by the bias, rounded
/// to the nearest integer and clipped to the ADC's range. The sensor pass on the GPU
/// does the same in f32.
pub fn dn(&self, electrons: f64) -> u16 {
let charge = electrons.clamp(0.0, self.full_well);
(charge / self.gain + self.bias)
.round()
.clamp(0.0, self.dn_max() as f64) as u16
}
}
/// A pinhole camera with radiometric optics and a detector.
#[derive(Clone, Debug, PartialEq)]
pub struct CameraConfig {
@@ -232,6 +250,19 @@ mod tests {
assert!(camera.validate().is_err());
}
#[test]
fn detector_saturates_at_full_well_then_adc() {
let d = CameraConfig::example().detector;
assert_eq!(d.dn(0.0), 64);
assert_eq!(d.dn(-5.0), 64);
assert_eq!(d.dn(11_138.8), 2292);
// The full well fills before the ADC tops out: 20,000 / 5 + 64 = 4064 < 4095.
assert_eq!(d.dn(20_000.0), 4064);
assert_eq!(d.dn(5e8), 4064);
let tight = Detector { gain: 1.0, ..d };
assert_eq!(tight.dn(1e6), 4095);
}
#[test]
fn boresight_projects_to_principal_point() {
let k = CameraConfig::example().intrinsics();
+1
View File
@@ -3,6 +3,7 @@
pub mod bodies;
pub mod context;
pub mod mesh;
pub mod sensor;
pub mod targets;
pub use context::{adapters, AdapterChoice, GpuContext};
+124
View File
@@ -0,0 +1,124 @@
//! The pass that turns channel radiance into the camera's DN.
//!
//! It is the last pass of a frame: a compute shader that reads the radiance target and
//! writes [`FrameTargets::dn`]. No noise yet (M3); the result for each pixel is
//! [`Detector::dn`](crate::camera::Detector::dn) of [`Radiometry::electrons`].
use super::targets::FrameTargets;
use super::GpuContext;
use crate::camera::CameraConfig;
use crate::radiometry::Radiometry;
/// The shader's uniform block; the layout matches `Sensor` in `sensor.wgsl`.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SensorUniforms {
intrinsics: [f32; 4],
detector: [f32; 4],
dims: [u32; 4],
}
impl SensorUniforms {
/// The constants for `camera`, whose channels `radiometry` was built from. They do
/// not change from frame to frame.
pub fn new(camera: &CameraConfig, radiometry: &Radiometry) -> Self {
let k = camera.intrinsics();
let d = &camera.detector;
Self {
intrinsics: [k.fx, k.fy, k.cx, k.cy].map(|x| x as f32),
detector: [
radiometry.electrons_per_radiance,
d.full_well,
d.gain,
d.bias,
]
.map(|x| x as f32),
dims: [
camera.width,
camera.height,
camera.channels.len() as u32,
d.dn_max(),
],
}
}
}
const WORKGROUP: u32 = 256;
/// The compiled sensor pipeline and its uniform buffer.
#[derive(Debug)]
pub struct SensorPass {
pipeline: wgpu::ComputePipeline,
uniforms: wgpu::Buffer,
}
impl SensorPass {
/// Compiles the shader and allocates the uniform buffer.
pub fn new(ctx: &GpuContext) -> Self {
let module = ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("sensor"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/sensor.wgsl").into()),
});
let pipeline = ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("sensor"),
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let uniforms = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sensor uniforms"),
size: std::mem::size_of::<SensorUniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self { pipeline, uniforms }
}
/// Uploads `uniforms` and records the pass into `encoder`, reading `targets`'
/// radiance and writing its DN buffer.
pub fn encode(
&self,
ctx: &GpuContext,
encoder: &mut wgpu::CommandEncoder,
targets: &FrameTargets,
uniforms: &SensorUniforms,
) {
ctx.queue
.write_buffer(&self.uniforms, 0, bytemuck::bytes_of(uniforms));
let radiance = targets.radiance.create_view(&Default::default());
let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sensor"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&radiance),
},
wgpu::BindGroupEntry {
binding: 1,
resource: targets.dn.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms.as_entire_binding(),
},
],
});
// One invocation per u32 word, in rows of at most 65,535 workgroups.
let groups = (targets.dn.size() / 4).div_ceil(WORKGROUP as u64) as u32;
let (x, y) = (groups.min(65_535), groups.div_ceil(65_535));
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("sensor"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(x, y, 1);
}
}
+51 -3
View File
@@ -21,11 +21,16 @@ pub struct FrameTargets {
pub radiance: wgpu::Texture,
/// Depth, [`DEPTH_FORMAT`].
pub depth: wgpu::Texture,
/// Number of camera channels.
pub channels: u32,
/// The sensor pass's output: u16 DN, channels interleaved per pixel, rows from the
/// top, packed two to a u32 word.
pub dn: wgpu::Buffer,
}
impl FrameTargets {
/// Allocates targets of `width` x `height` pixels.
pub fn new(ctx: &GpuContext, width: u32, height: u32) -> Self {
/// Allocates targets of `width` x `height` pixels with `channels` channels.
pub fn new(ctx: &GpuContext, width: u32, height: u32, channels: u32) -> Self {
let texture = |label, format, usage| {
ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
@@ -45,14 +50,57 @@ impl FrameTargets {
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC;
let values = width as u64 * height as u64 * channels as u64;
Self {
width,
height,
radiance: texture("radiance", RADIANCE_FORMAT, usage),
// Writable from the CPU so tests can supply radiance directly.
radiance: texture(
"radiance",
RADIANCE_FORMAT,
usage | wgpu::TextureUsages::COPY_DST,
),
depth: texture("depth", DEPTH_FORMAT, usage),
channels,
dn: ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dn"),
size: values.div_ceil(2) * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
}),
}
}
/// Number of u16 values in the DN image.
pub fn dn_len(&self) -> usize {
self.width as usize * self.height as usize * self.channels as usize
}
/// Reads the DN image back. Slow, like [`FrameTargets::read_radiance`]; the per-frame
/// readback is the renderer's.
pub fn read_dn(&self, ctx: &GpuContext) -> Vec<u16> {
let staging = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dn readback"),
size: self.dn.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = ctx.device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&self.dn, 0, &staging, 0, self.dn.size());
ctx.queue.submit([encoder.finish()]);
staging
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.expect("readback map"));
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("readback poll");
let view = staging
.slice(..)
.get_mapped_range()
.expect("readback range");
bytemuck::cast_slice::<u8, u16>(&view)[..self.dn_len()].to_vec()
}
/// Reads the radiance target back, row by row from the top. Slow; for tests and
/// debugging, not the per-frame path.
pub fn read_radiance(&self, ctx: &GpuContext) -> Vec<[f32; 4]> {
+47
View File
@@ -0,0 +1,47 @@
// Channel radiance to DN: the camera equation, then the detector.
//
// Output is u16 per channel, channels interleaved per pixel, rows from the top. WGSL
// has no 16-bit integers, so each invocation computes two consecutive values and packs
// them into one u32 word, low half first. Two invocations never touch the same word.
struct Sensor {
// fx, fy, cx, cy in pixels.
intrinsics: vec4<f32>,
// Electrons per unit channel radiance on axis, full well, gain (e-/DN), bias (DN).
detector: vec4<f32>,
// Width, height, channel count, largest DN.
dims: vec4<u32>,
}
@group(0) @binding(0) var radiance: texture_2d<f32>;
@group(0) @binding(1) var<storage, read_write> dn: array<u32>;
@group(0) @binding(2) var<uniform> s: Sensor;
fn value(i: u32) -> u32 {
let n = s.dims.z;
if i >= s.dims.x * s.dims.y * n {
return 0u;
}
let pixel = i / n;
let x = pixel % s.dims.x;
let y = pixel / s.dims.x;
let l = textureLoad(radiance, vec2<u32>(x, y), 0)[i % n];
// Off-axis falloff: cos^4 of the angle between this pixel's line of sight and the
// boresight.
let k = s.intrinsics;
let ray = vec3<f32>((f32(x) + 0.5 - k.z) / k.x, (f32(y) + 0.5 - k.w) / k.y, 1.0);
let c2 = 1.0 / dot(ray, ray);
let electrons = clamp(l * s.detector.x * c2 * c2, 0.0, s.detector.y);
let level = floor(electrons / s.detector.z + s.detector.w + 0.5);
return u32(clamp(level, 0.0, f32(s.dims.w)));
}
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) id: vec3<u32>, @builtin(num_workgroups) groups: vec3<u32>) {
let word = id.x + id.y * groups.x * 256u;
if word >= arrayLength(&dn) {
return;
}
dn[word] = value(2u * word) | (value(2u * word + 1u) << 16u);
}
+20 -19
View File
@@ -7,7 +7,7 @@ use approx::assert_relative_eq;
use resolved::bodies::{Appearance, EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS};
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::targets::FrameTargets;
use resolved::gpu::{AdapterChoice, GpuContext};
use resolved::gpu::GpuContext;
use resolved::nalgebra::{UnitQuaternion, Vector3};
use resolved::radiometry::{Radiometry, AU, SUN_RADIUS};
use resolved::{CameraConfig, Pose, Scene};
@@ -29,10 +29,6 @@ impl Frame {
}
}
fn contexts() -> [GpuContext; 2] {
[AdapterChoice::Gpu, AdapterChoice::Cpu].map(|c| GpuContext::new(&c).unwrap())
}
fn body_radiometry() -> BodyRadiometry {
let radiometry = Radiometry::new(&CameraConfig::example());
BodyRadiometry::new(&radiometry, &Appearance::default()).unwrap()
@@ -49,7 +45,12 @@ fn render_with(
camera: &Pose,
config: &CameraConfig,
) -> Frame {
let targets = FrameTargets::new(ctx, config.width, config.height);
let targets = FrameTargets::new(
ctx,
config.width,
config.height,
config.channels.len() as u32,
);
let pass = BodiesPass::new(ctx);
let uniforms = BodiesUniforms::new(
scene,
@@ -100,8 +101,8 @@ fn sunlit_earth_center_radiance_and_depth() {
let scene = scene(Vector3::new(AU, 0.0, 0.0), moon_away());
let expected = body_radiometry().earth_lambert_1au;
let near = CameraConfig::example().near_clip;
for ctx in contexts() {
let frame = render(&ctx, "earth-sunlit", &scene, &earth_camera());
for ctx in common::contexts() {
let frame = render(ctx, "earth-sunlit", &scene, &earth_camera());
let (radiance, depth) = frame.at(960, 540);
for k in 0..3 {
assert_relative_eq!(radiance[k], expected[k], max_relative = 1e-4);
@@ -122,8 +123,8 @@ fn earth_limb_lands_where_projection_says() {
let expected = (0..1920)
.filter(|u| ((*u as f64 + 0.5) - k.cx).abs() < half_width)
.count();
for ctx in contexts() {
let frame = render(&ctx, "earth-limb", &scene, &earth_camera());
for ctx in common::contexts() {
let frame = render(ctx, "earth-limb", &scene, &earth_camera());
let hit = (0..frame.width)
.filter(|&u| frame.at(u, 540).1 > 0.0)
.count();
@@ -140,8 +141,8 @@ fn night_side_is_dark_and_hides_the_sun() {
// The Sun directly behind Earth: the visible hemisphere is unlit and the solar disk
// is eclipsed.
let scene = scene(Vector3::new(-AU, 0.0, 0.0), moon_away());
for ctx in contexts() {
let frame = render(&ctx, "earth-night", &scene, &earth_camera());
for ctx in common::contexts() {
let frame = render(ctx, "earth-night", &scene, &earth_camera());
assert!(
frame.radiance.iter().all(|r| *r == [0.0; 4]),
"{}",
@@ -162,8 +163,8 @@ fn solar_disk_size_and_radiance() {
let radius = k.fx * (SUN_RADIUS / (sun - position).norm()).asin().tan();
let expected = std::f64::consts::PI * radius * radius;
let sun_radiance = body_radiometry().sun_radiance;
for ctx in contexts() {
let frame = render(&ctx, "sun-disk", &scene, &camera);
for ctx in common::contexts() {
let frame = render(ctx, "sun-disk", &scene, &camera);
assert_eq!(frame.at(960, 540).0, sun_radiance);
let lit = frame
.radiance
@@ -186,8 +187,8 @@ fn moon_in_front_of_earth_wins() {
.moon_lambert_1au
.map(|l| l / (au * au) as f32)
};
for ctx in contexts() {
let frame = render(&ctx, "moon-in-front-of-earth", &scene, &earth_camera());
for ctx in common::contexts() {
let frame = render(ctx, "moon-in-front-of-earth", &scene, &earth_camera());
let (radiance, depth) = frame.at(960, 540);
for k in 0..3 {
assert_relative_eq!(radiance[k], expected[k], max_relative = 1e-4);
@@ -214,10 +215,10 @@ fn limb_follows_the_ellipsoid() {
let x = a * a / RANGE;
let north = Vector3::new(x, 0.0, b * (1.0 - (a / RANGE).powi(2)).sqrt());
let east = Vector3::new(x, a * (1.0 - (a / RANGE).powi(2)).sqrt(), 0.0);
for ctx in contexts() {
for ctx in common::contexts() {
// Boresight on the north limb, north up: Earth fills rows below the center.
let camera = Pose::looking_at(position, north, Vector3::z()).unwrap();
let frame = render_with(&ctx, "limb-north", &scene, &camera, &config);
let frame = render_with(ctx, "limb-north", &scene, &camera, &config);
let column: Vec<bool> = (0..1080).map(|v| frame.at(960, v).1 > 0.0).collect();
let edge = column.iter().position(|hit| *hit).unwrap();
assert!(column[edge..].iter().all(|hit| *hit));
@@ -229,7 +230,7 @@ fn limb_follows_the_ellipsoid() {
// Boresight on the east limb, north up: Earth is to the left of center.
let camera = Pose::looking_at(position, east, Vector3::z()).unwrap();
let frame = render_with(&ctx, "limb-east", &scene, &camera, &config);
let frame = render_with(ctx, "limb-east", &scene, &camera, &config);
let row: Vec<bool> = (0..1920).map(|u| frame.at(u, 540).1 > 0.0).collect();
let edge = row.iter().position(|hit| !*hit).unwrap();
assert!(row[edge..].iter().all(|hit| !*hit));
+46 -15
View File
@@ -1,14 +1,27 @@
//! Shared by the GPU integration tests: saving what a test rendered, for looking at.
//! Shared by the GPU integration tests: the devices to run on, and saving what a test
//! rendered, for looking at.
//!
//! Set `RESOLVED_TEST_IMAGES=1` and each test writes PNGs to
//! `target/tmp/test-images/`, one per test case and device.
use std::path::PathBuf;
// Each test binary compiles this module and uses only some of it.
#![allow(dead_code)]
use resolved::gpu::GpuContext;
use std::path::PathBuf;
use std::sync::OnceLock;
use resolved::gpu::{AdapterChoice, GpuContext};
use resolved::radiometry::{Radiometry, AU};
use resolved::CameraConfig;
/// A hardware GPU and lavapipe, opened once per test binary and shared by its tests.
pub fn contexts() -> &'static [GpuContext; 2] {
static CONTEXTS: OnceLock<[GpuContext; 2]> = OnceLock::new();
CONTEXTS.get_or_init(|| {
[AdapterChoice::Gpu, AdapterChoice::Cpu].map(|c| GpuContext::new(&c).unwrap())
})
}
/// Writes `radiance` (example camera, row-major from the top) as an sRGB PNG named
/// `<name>-<gpu|cpu>.png`, if `RESOLVED_TEST_IMAGES` is set.
///
@@ -19,32 +32,50 @@ pub fn save(name: &str, ctx: &GpuContext, radiance: &[[f32; 4]]) {
if std::env::var_os("RESOLVED_TEST_IMAGES").is_none() {
return;
}
let config = CameraConfig::example();
let r = Radiometry::new(&config);
let r = Radiometry::new(&CameraConfig::example());
let white: Vec<f32> = (0..3)
.map(|k| r.lambert_radiance(k, 1.0, 1.0, AU) as f32)
.collect();
let srgb = |x: f32| {
let x = x.clamp(0.0, 1.0);
let v = if x <= 0.003_130_8 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
};
(v * 255.0).round() as u8
};
let pixels: Vec<u8> = radiance
.iter()
.flat_map(|p| (0..3).map(move |k| (k, p[k])))
.map(|(k, v)| srgb(v / white[k]))
.collect();
write(name, ctx, &pixels);
}
/// Writes a DN image (example camera, three channels interleaved) as an sRGB PNG named
/// `<name>-dn-<gpu|cpu>.png`, if `RESOLVED_TEST_IMAGES` is set. The ADC's full range
/// maps to black through white, as a raw camera frame would display.
pub fn save_dn(name: &str, ctx: &GpuContext, dn: &[u16]) {
if std::env::var_os("RESOLVED_TEST_IMAGES").is_none() {
return;
}
let max = CameraConfig::example().detector.dn_max() as f32;
let pixels: Vec<u8> = dn.iter().map(|&d| srgb(d as f32 / max)).collect();
write(&format!("{name}-dn"), ctx, &pixels);
}
/// Linear 0..1 to 8-bit sRGB.
fn srgb(x: f32) -> u8 {
let x = x.clamp(0.0, 1.0);
let v = if x <= 0.003_130_8 {
12.92 * x
} else {
1.055 * x.powf(1.0 / 2.4) - 0.055
};
(v * 255.0).round() as u8
}
fn write(name: &str, ctx: &GpuContext, rgb: &[u8]) {
let config = CameraConfig::example();
let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("test-images");
std::fs::create_dir_all(&dir).unwrap();
let device = if ctx.is_cpu() { "cpu" } else { "gpu" };
let path = dir.join(format!("{name}-{device}.png"));
image::save_buffer(
&path,
&pixels,
rgb,
config.width,
config.height,
image::ExtendedColorType::Rgb8,
+17 -16
View File
@@ -11,7 +11,7 @@ use resolved::eclipse::sun_visible_fraction;
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::mesh::{MeshDraw, MeshPass};
use resolved::gpu::targets::FrameTargets;
use resolved::gpu::{AdapterChoice, GpuContext};
use resolved::gpu::GpuContext;
use resolved::model::{LoadOptions, Material, Mesh, Part};
use resolved::nalgebra::{UnitQuaternion, Vector3};
use resolved::radiometry::{Radiometry, AU};
@@ -30,10 +30,6 @@ impl Frame {
}
}
fn contexts() -> [GpuContext; 2] {
[AdapterChoice::Gpu, AdapterChoice::Cpu].map(|c| GpuContext::new(&c).unwrap())
}
/// A cube with edges of 2 `scale` meters, centered on its origin, gray albedo 0.5.
fn cube(scale: f64) -> Mesh {
let mut s = String::from("solid cube\n");
@@ -88,7 +84,12 @@ fn render(ctx: &GpuContext, name: &str, scene: &Scene, camera: &Pose, mesh: &Mes
let config = CameraConfig::example();
let k = config.intrinsics();
let radiometry = Radiometry::new(&config);
let targets = FrameTargets::new(ctx, config.width, config.height);
let targets = FrameTargets::new(
ctx,
config.width,
config.height,
config.channels.len() as u32,
);
let bodies = BodiesPass::new(ctx);
let mut meshes = MeshPass::new(ctx);
let gpu_mesh = meshes.upload(ctx, mesh, &radiometry).unwrap();
@@ -142,8 +143,8 @@ fn sunlit_face_radiance_depth_and_silhouette() {
let expected_width = (0..1920)
.filter(|u| ((*u as f64 + 0.5) - k.cx).abs() < half_width)
.count();
for ctx in contexts() {
let frame = render(&ctx, "cube-sunlit", &scene, &camera, &cube(1.0));
for ctx in common::contexts() {
let frame = render(ctx, "cube-sunlit", &scene, &camera, &cube(1.0));
let (radiance, depth) = frame.at(960, 540);
for c in 0..3 {
assert_relative_eq!(radiance[c], expected[c], max_relative = 1e-4);
@@ -173,10 +174,10 @@ fn earth_hides_a_target_behind_it_but_not_in_front() {
)
.unwrap();
let big = cube(5.0e4);
for ctx in contexts() {
let empty = render(&ctx, "cube-scene-empty", &scene(sun, None), &camera, &big);
for ctx in common::contexts() {
let empty = render(ctx, "cube-scene-empty", &scene(sun, None), &camera, &big);
let behind = render(
&ctx,
ctx,
"cube-behind-earth",
&scene(sun, Some(identity_at(Vector3::new(-7.0e6, 0.0, 0.0)))),
&camera,
@@ -188,7 +189,7 @@ fn earth_hides_a_target_behind_it_but_not_in_front() {
ctx.info.name
);
let front = render(
&ctx,
ctx,
"cube-in-front-of-earth",
&scene(sun, Some(identity_at(Vector3::new(7.0e6, 0.0, 0.0)))),
&camera,
@@ -210,14 +211,14 @@ fn penumbra_dims_and_umbra_darkens() {
// Targets 7000 km behind Earth, one on the shadow axis and one at Earth's radius
// off it (inside the penumbra), each seen from 30 m on its sunward side.
let sun = Vector3::new(AU, 0.0, 0.0);
for ctx in contexts() {
for ctx in common::contexts() {
for y in [0.0, EARTH_EQUATORIAL_RADIUS] {
let target = Vector3::new(-7.0e6, y, 0.0);
let camera =
Pose::looking_at(target + Vector3::new(30.0, 0.0, 0.0), target, Vector3::z())
.unwrap();
let frame = render(
&ctx,
ctx,
&format!(
"cube-shadow-{}",
if y == 0.0 { "umbra" } else { "penumbra" }
@@ -271,9 +272,9 @@ fn thin_panel_is_lit_on_the_side_facing_the_camera() {
let camera =
Pose::looking_at(target + Vector3::new(30.0, 0.0, 0.0), target, Vector3::z()).unwrap();
let expected = facing_sun((sun - target).norm());
for ctx in contexts() {
for ctx in common::contexts() {
let frame = render(
&ctx,
ctx,
"thin-panel",
&scene(sun, Some(identity_at(target))),
&camera,
+166
View File
@@ -0,0 +1,166 @@
//! The sensor pass against the CPU reference (`Radiometry::electrons` then
//! `Detector::dn`), on the GPU and on the CPU (lavapipe).
mod common;
use resolved::bodies::Appearance;
use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry};
use resolved::gpu::sensor::{SensorPass, SensorUniforms};
use resolved::gpu::targets::FrameTargets;
use resolved::gpu::GpuContext;
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
use resolved::radiometry::{Radiometry, AU};
use resolved::{CameraConfig, Pose, Scene};
/// Runs the sensor pass over `radiance` (example camera, rows from the top) and
/// returns the DN image.
fn sense(ctx: &GpuContext, name: &str, radiance: &[[f32; 4]]) -> Vec<u16> {
let config = CameraConfig::example();
let targets = FrameTargets::new(ctx, config.width, config.height, 3);
ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &targets.radiance,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
bytemuck::cast_slice(radiance),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(config.width * 16),
rows_per_image: Some(config.height),
},
targets.radiance.size(),
);
let pass = SensorPass::new(ctx);
let uniforms = SensorUniforms::new(&config, &Radiometry::new(&config));
let mut encoder = ctx.device.create_command_encoder(&Default::default());
pass.encode(ctx, &mut encoder, &targets, &uniforms);
ctx.queue.submit([encoder.finish()]);
let dn = targets.read_dn(ctx);
common::save_dn(name, ctx, &dn);
dn
}
/// The DN the CPU reference gives for `radiance` in channel `c` at pixel (u, v).
fn reference(config: &CameraConfig, r: &Radiometry, radiance: f32, u: usize, v: usize) -> u16 {
let ray = config
.intrinsics()
.ray(&Vector2::new(u as f64 + 0.5, v as f64 + 0.5));
config.detector.dn(r.electrons(radiance as f64, ray.z))
}
/// Channel radiance of a white Lambertian surface facing the Sun at 1 AU.
fn white() -> [f32; 4] {
let r = Radiometry::new(&CameraConfig::example());
let mut out = [0.0; 4];
for (k, o) in out.iter_mut().take(3).enumerate() {
*o = r.lambert_radiance(k, 1.0, 1.0, AU) as f32;
}
out
}
#[test]
fn every_pixel_matches_the_cpu_reference() {
// A pattern from black to three times the white level (past saturation), different
// in each channel and pixel.
let config = CameraConfig::example();
let r = Radiometry::new(&config);
let w = white();
let level = |u: usize, v: usize, c: usize| ((u * 7 + v * 13 + c * 5) % 1000) as f32 / 333.0;
let radiance: Vec<[f32; 4]> = (0..1080)
.flat_map(|v| (0..1920).map(move |u| (u, v)))
.map(|(u, v)| [0, 1, 2, 3].map(|c| if c < 3 { w[c] * level(u, v, c) } else { 0.0 }))
.collect();
for ctx in common::contexts() {
let dn = sense(ctx, "sensor-pattern", &radiance);
let mut off_by_one = 0;
// Every 8th row, plus the last: enough to catch any indexing mistake.
for v in (0..1080).step_by(8).chain([1079]) {
for u in 0..1920 {
for c in 0..3 {
let got = dn[(v * 1920 + u) * 3 + c];
let want = reference(&config, &r, radiance[v * 1920 + u][c], u, v);
assert!(
got.abs_diff(want) <= 1,
"{} ({u},{v},{c}): {got} vs {want}",
ctx.info.name
);
off_by_one += usize::from(got != want);
}
}
}
// f32 on the GPU and f64 on the CPU disagree only where a value lands within
// rounding of a half-DN boundary: measured 557 of all 6.2 million values on the
// RTX 3060 and 699 on lavapipe, about 1 in 10,000.
assert!(
off_by_one < 1920 * 136 * 3 / 5000,
"{}: {off_by_one} off by one",
ctx.info.name
);
}
}
#[test]
fn worked_example_white_surface_saturation_and_bias() {
let w = white();
for ctx in common::contexts() {
// A white surface facing the Sun fills the frame: docs/optics.md's worked example.
let dn = sense(ctx, "sensor-white", &vec![w; 1920 * 1080]);
let green = |u: usize, v: usize| dn[(v * 1920 + u) * 3 + 1];
assert_eq!(green(960, 540), 2292);
// cos^4 falloff: 6.9 degrees off axis at the corner, 10,819 electrons instead of
// 11,139, so 64 + 10,819 / 5 = 2227.8.
assert_eq!(green(0, 0), 2228);
// Looking straight at the Sun: the full well caps the signal at 20,000 / 5 + 64.
let sun = w.map(|x| x * 5e4);
assert!(sense(ctx, "sensor-sun", &vec![sun; 1920 * 1080])
.iter()
.all(|&d| d == 4064));
// Darkness reads as the bias.
assert!(sense(ctx, "sensor-dark", &vec![[0.0; 4]; 1920 * 1080])
.iter()
.all(|&d| d == 64));
}
}
#[test]
fn sunlit_earth_through_the_whole_chain() {
// Earth seen from 200,000 km with the Sun behind the camera: the sub-camera point
// has albedo 0.3 facing the Sun, so 0.3 x 11,138.8 electrons in green: 732 DN.
let config = CameraConfig::example();
let radiometry = Radiometry::new(&config);
let scene = Scene {
earth_attitude: UnitQuaternion::identity(),
sun: Vector3::new(AU, 0.0, 0.0),
moon: Vector3::new(0.0, -3.844e8, 0.0),
targets: vec![],
};
let camera = Pose::looking_at(
Vector3::new(2.0e8, 0.0, 0.0),
Vector3::zeros(),
Vector3::z(),
)
.unwrap();
let body_radiometry = BodyRadiometry::new(&radiometry, &Appearance::default()).unwrap();
let bodies = BodiesUniforms::new(
&scene,
&camera,
&config.intrinsics(),
config.near_clip,
&body_radiometry,
);
let sensor = SensorUniforms::new(&config, &radiometry);
for ctx in common::contexts() {
let targets = FrameTargets::new(ctx, config.width, config.height, 3);
let mut encoder = ctx.device.create_command_encoder(&Default::default());
BodiesPass::new(ctx).encode(ctx, &mut encoder, &targets, &bodies);
SensorPass::new(ctx).encode(ctx, &mut encoder, &targets, &sensor);
ctx.queue.submit([encoder.finish()]);
let dn = targets.read_dn(ctx);
common::save_dn("sensor-earth", ctx, &dn);
assert_eq!(dn[(540 * 1920 + 960) * 3 + 1], 732);
assert_eq!(dn[0], 64, "space reads as the bias");
}
}