diff --git a/crates/resolved/src/error.rs b/crates/resolved/src/error.rs index bdf34a5..956ebfa 100644 --- a/crates/resolved/src/error.rs +++ b/crates/resolved/src/error.rs @@ -25,6 +25,10 @@ pub enum Error { #[error("unsupported: {0}")] Unsupported(String), + /// The GPU failed while rendering or reading back a frame. + #[error("GPU: {0}")] + Gpu(String), + /// The adapter refused to open a device. #[error("could not open a device: {0}")] RequestDevice(#[from] wgpu::RequestDeviceError), diff --git a/crates/resolved/src/lib.rs b/crates/resolved/src/lib.rs index 290d6b5..4344624 100644 --- a/crates/resolved/src/lib.rs +++ b/crates/resolved/src/lib.rs @@ -24,13 +24,15 @@ pub mod error; pub mod gpu; pub mod model; pub mod radiometry; +pub mod render; pub mod scene; mod solar_data; pub mod spectrum; pub use camera::{CameraConfig, Channel, Detector, Intrinsics}; pub use error::Error; -pub use model::{LoadOptions, Material, Mesh}; +pub use model::{LoadOptions, Material, Mesh, ModelId}; pub use radiometry::Radiometry; +pub use render::{Frame, Pending, Renderer, RendererOptions}; pub use scene::{CameraRelative, Placement, Pose, Scene, Target}; pub use spectrum::Spectrum; diff --git a/crates/resolved/src/model.rs b/crates/resolved/src/model.rs index 1e9fbac..21373be 100644 --- a/crates/resolved/src/model.rs +++ b/crates/resolved/src/model.rs @@ -49,6 +49,12 @@ impl Material { } } +/// A model loaded into a [`Renderer`](crate::Renderer), as returned by +/// [`Renderer::load_model`](crate::Renderer::load_model). The number is the order +/// models were loaded in, from 0. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ModelId(pub usize); + /// A named piece of a model, drawn with one material. #[derive(Clone, Debug, PartialEq)] pub struct Part { diff --git a/crates/resolved/src/render.rs b/crates/resolved/src/render.rs new file mode 100644 index 0000000..315a031 --- /dev/null +++ b/crates/resolved/src/render.rs @@ -0,0 +1,317 @@ +//! The renderer: everything set up once, then one frame per call. +//! +//! [`Renderer::new`] opens the device, compiles every pass, folds the camera's spectra +//! into per-channel constants and allocates the frame's targets. [`Renderer::load_model`] +//! uploads a model once. Each frame is then [`Renderer::submit`], which records and +//! queues the GPU work and returns at once, and [`Pending::wait`], which blocks until +//! the DN image is back. Work the caller does in between overlaps the GPU's. + +use std::sync::mpsc; + +use nalgebra::Vector3; + +use crate::bodies::{Appearance, MOON_RADIUS}; +use crate::camera::CameraConfig; +use crate::eclipse::sun_visible_fraction; +use crate::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry}; +use crate::gpu::mesh::{GpuMesh, MeshDraw, MeshPass}; +use crate::gpu::sensor::{SensorPass, SensorUniforms}; +use crate::gpu::targets::FrameTargets; +use crate::gpu::{AdapterChoice, GpuContext}; +use crate::model::{Mesh, ModelId}; +use crate::radiometry::Radiometry; +use crate::scene::{Pose, Scene}; +use crate::Error; + +/// Choices made once, when the renderer is created. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RendererOptions { + /// Which device to render on. + pub adapter: AdapterChoice, + /// How Earth and the Moon reflect light. + pub appearance: Appearance, + /// Also read back the channel radiance image each frame. Costs another 16 bytes per + /// pixel of transfer; for debugging and tests. + pub read_radiance: bool, +} + +/// One rendered image. +#[derive(Clone, Debug, PartialEq)] +pub struct Frame { + /// Image width in pixels. + pub width: u32, + /// Image height in pixels. + pub height: u32, + /// Number of channels. + pub channels: u32, + /// DN, channels interleaved per pixel, rows from the top. + pub dn: Vec, + /// Channel radiance, if [`RendererOptions::read_radiance`] was set. + pub radiance: Option>, +} + +impl Frame { + /// The DN of channel `c` at pixel column `u`, row `v`. + pub fn dn_at(&self, u: u32, v: u32, c: u32) -> u16 { + self.dn[((v * self.width + u) * self.channels + c) as usize] + } +} + +/// Renders frames for one camera. +#[derive(Debug)] +pub struct Renderer { + ctx: GpuContext, + camera: CameraConfig, + radiometry: Radiometry, + body_radiometry: BodyRadiometry, + targets: FrameTargets, + bodies: BodiesPass, + meshes: MeshPass, + sensor: SensorPass, + sensor_uniforms: SensorUniforms, + models: Vec, + dn_staging: wgpu::Buffer, + radiance_staging: Option<(wgpu::Buffer, u32)>, +} + +impl Renderer { + /// Sets up everything that does not change from frame to frame. + pub fn new(camera: CameraConfig, options: RendererOptions) -> Result { + camera.validate()?; + let ctx = GpuContext::new(&options.adapter)?; + let radiometry = Radiometry::new(&camera); + let body_radiometry = BodyRadiometry::new(&radiometry, &options.appearance)?; + let targets = FrameTargets::new( + &ctx, + camera.width, + camera.height, + camera.channels.len() as u32, + ); + let staging = |label, size| { + ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + }; + let dn_staging = staging("dn staging", targets.dn.size()); + let radiance_staging = options.read_radiance.then(|| { + // Buffer rows must start on 256-byte boundaries. + let row = (camera.width * 16).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT); + ( + staging("radiance staging", row as u64 * camera.height as u64), + row, + ) + }); + Ok(Self { + bodies: BodiesPass::new(&ctx), + meshes: MeshPass::new(&ctx), + sensor: SensorPass::new(&ctx), + sensor_uniforms: SensorUniforms::new(&camera, &radiometry), + ctx, + camera, + radiometry, + body_radiometry, + targets, + models: Vec::new(), + dn_staging, + radiance_staging, + }) + } + + /// The device this renders on. + pub fn adapter_info(&self) -> &wgpu::AdapterInfo { + &self.ctx.info + } + + /// The camera this renders for. + pub fn camera(&self) -> &CameraConfig { + &self.camera + } + + /// Uploads a model, to be drawn by any [`Target`](crate::Target) that names the + /// returned id. + pub fn load_model(&mut self, mesh: &Mesh) -> Result { + let gpu = self.meshes.upload(&self.ctx, mesh, &self.radiometry)?; + self.models.push(gpu); + Ok(ModelId(self.models.len() - 1)) + } + + /// Queues one frame of `scene`, seen by the camera on an observer at `observer`, and + /// returns without waiting for it. + /// + /// The camera's pose is `observer.then(&camera.mount)`. Each target is lit by the + /// fraction of the solar disk Earth and the Moon leave visible from its center. + pub fn submit(&mut self, scene: &Scene, observer: &Pose) -> Result, Error> { + if let Some(t) = scene + .targets + .iter() + .find(|t| t.model.0 >= self.models.len()) + { + return Err(Error::Unsupported(format!( + "target names {:?}, but only {} models are loaded", + t.model, + self.models.len() + ))); + } + let camera = observer.then(&self.camera.mount); + let intrinsics = self.camera.intrinsics(); + let near = self.camera.near_clip; + let occulters = [ + (Vector3::zeros(), crate::bodies::EARTH_EQUATORIAL_RADIUS), + (scene.moon, MOON_RADIUS), + ]; + + let bodies = BodiesUniforms::new(scene, &camera, &intrinsics, near, &self.body_radiometry); + let draws: Vec<(&GpuMesh, MeshDraw)> = scene + .targets + .iter() + .map(|t| { + let visible = sun_visible_fraction(t.pose.position, scene.sun, &occulters); + let draw = MeshDraw::new(&t.pose, &camera, &intrinsics, near, scene.sun, visible); + (&self.models[t.model.0], draw) + }) + .collect(); + + let ctx = &self.ctx; + let mut encoder = ctx.device.create_command_encoder(&Default::default()); + self.bodies + .encode(ctx, &mut encoder, &self.targets, &bodies); + self.meshes.encode(ctx, &mut encoder, &self.targets, &draws); + self.sensor + .encode(ctx, &mut encoder, &self.targets, &self.sensor_uniforms); + encoder.copy_buffer_to_buffer( + &self.targets.dn, + 0, + &self.dn_staging, + 0, + self.targets.dn.size(), + ); + if let Some((buffer, row)) = &self.radiance_staging { + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.targets.radiance, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(*row), + rows_per_image: Some(self.camera.height), + }, + }, + self.targets.radiance.size(), + ); + } + let index = ctx.queue.submit([encoder.finish()]); + + // Ask for the staging buffers now; the mapping completes when the GPU finishes. + let (sender, mapped) = mpsc::channel(); + let staged = + std::iter::once(&self.dn_staging).chain(self.radiance_staging.as_ref().map(|(b, _)| b)); + for buffer in staged { + let sender = sender.clone(); + buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |r| sender.send(r).unwrap_or(())); + } + Ok(Pending { + renderer: self, + index: Some(index), + mapped, + }) + } +} + +/// A frame the GPU is working on. [`Pending::wait`] returns it. +/// +/// Holding a `Pending` borrows the renderer, so only one frame is in flight at a time. +/// Dropping one without waiting still waits, so the next frame starts clean. +#[must_use = "a submitted frame is only read back by `wait`"] +#[derive(Debug)] +pub struct Pending<'a> { + renderer: &'a mut Renderer, + index: Option, + mapped: mpsc::Receiver>, +} + +impl Pending<'_> { + /// Blocks until the GPU has finished this frame and returns it. + pub fn wait(mut self) -> Result { + self.block()?; + let r = &*self.renderer; + let dn = { + let view = r + .dn_staging + .slice(..) + .get_mapped_range() + .map_err(gpu_error)?; + bytemuck::cast_slice::(&view)[..r.targets.dn_len()].to_vec() + }; + r.dn_staging.unmap(); + let radiance = match &r.radiance_staging { + None => None, + Some((buffer, row)) => { + let texels = { + let view = buffer.slice(..).get_mapped_range().map_err(gpu_error)?; + let width = (r.camera.width * 16) as usize; + view.chunks(*row as usize) + .flat_map(|chunk| { + bytemuck::cast_slice::(&chunk[..width]) + .iter() + .copied() + }) + .collect() + }; + buffer.unmap(); + Some(texels) + } + }; + Ok(Frame { + width: r.camera.width, + height: r.camera.height, + channels: r.camera.channels.len() as u32, + dn, + radiance, + }) + } + + /// Waits for the submission and for every staging buffer to be mapped. + fn block(&mut self) -> Result<(), Error> { + let Some(index) = self.index.take() else { + return Ok(()); + }; + let r = &self.renderer; + r.ctx + .device + .poll(wgpu::PollType::Wait { + submission_index: Some(index), + timeout: None, + }) + .map_err(gpu_error)?; + let buffers = 1 + usize::from(r.radiance_staging.is_some()); + for _ in 0..buffers { + self.mapped.recv().map_err(gpu_error)?.map_err(gpu_error)?; + } + Ok(()) + } +} + +impl Drop for Pending<'_> { + fn drop(&mut self) { + if self.index.is_some() && self.block().is_ok() { + self.renderer.dn_staging.unmap(); + if let Some((buffer, _)) = &self.renderer.radiance_staging { + buffer.unmap(); + } + } + } +} + +fn gpu_error(e: impl std::fmt::Display) -> Error { + Error::Gpu(e.to_string()) +} diff --git a/crates/resolved/src/scene.rs b/crates/resolved/src/scene.rs index 91603a4..1cc97f5 100644 --- a/crates/resolved/src/scene.rs +++ b/crates/resolved/src/scene.rs @@ -15,6 +15,8 @@ use nalgebra::{UnitQuaternion, Vector3}; +use crate::model::ModelId; + /// A rigid position and orientation in the inertial frame. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Pose { @@ -65,6 +67,8 @@ impl Pose { /// A spacecraft that may appear in the image. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Target { + /// Which loaded model to draw. + pub model: ModelId, /// Where the target is and how its model frame is oriented. pub pose: Pose, } @@ -149,7 +153,10 @@ mod tests { earth_attitude: UnitQuaternion::identity(), sun: Vector3::new(AU, 0.0, 0.0), moon: Vector3::new(0.0, 3.844e8, 0.0), - targets: vec![Target { pose: target }], + targets: vec![Target { + model: ModelId(0), + pose: target, + }], } } diff --git a/crates/resolved/tests/bodies.rs b/crates/resolved/tests/bodies.rs index 475a45a..77c5f3d 100644 --- a/crates/resolved/tests/bodies.rs +++ b/crates/resolved/tests/bodies.rs @@ -63,7 +63,7 @@ fn render_with( pass.encode(ctx, &mut encoder, &targets, &uniforms); ctx.queue.submit([encoder.finish()]); let radiance = targets.read_radiance(ctx); - common::save(name, ctx, &radiance); + common::save(name, &ctx.info, &radiance); Frame { radiance, depth: targets.read_depth(ctx), diff --git a/crates/resolved/tests/common/mod.rs b/crates/resolved/tests/common/mod.rs index adf87f5..39f43bf 100644 --- a/crates/resolved/tests/common/mod.rs +++ b/crates/resolved/tests/common/mod.rs @@ -13,6 +13,7 @@ use std::sync::OnceLock; use resolved::gpu::{AdapterChoice, GpuContext}; use resolved::radiometry::{Radiometry, AU}; use resolved::CameraConfig; +use wgpu::AdapterInfo; /// A hardware GPU and lavapipe, opened once per test binary and shared by its tests. pub fn contexts() -> &'static [GpuContext; 2] { @@ -28,7 +29,7 @@ pub fn contexts() -> &'static [GpuContext; 2] { /// Channels r, g, b map to red, green, blue, scaled so a white Lambertian surface /// facing the Sun at 1 AU is full white. Brighter than that (the Sun) clips. This is a /// display of radiance, not a simulated camera image. -pub fn save(name: &str, ctx: &GpuContext, radiance: &[[f32; 4]]) { +pub fn save(name: &str, adapter: &AdapterInfo, radiance: &[[f32; 4]]) { if std::env::var_os("RESOLVED_TEST_IMAGES").is_none() { return; } @@ -41,19 +42,19 @@ pub fn save(name: &str, ctx: &GpuContext, radiance: &[[f32; 4]]) { .flat_map(|p| (0..3).map(move |k| (k, p[k]))) .map(|(k, v)| srgb(v / white[k])) .collect(); - write(name, ctx, &pixels); + write(name, adapter, &pixels); } /// Writes a DN image (example camera, three channels interleaved) as an sRGB PNG named /// `-dn-.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]) { +pub fn save_dn(name: &str, adapter: &AdapterInfo, 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 = dn.iter().map(|&d| srgb(d as f32 / max)).collect(); - write(&format!("{name}-dn"), ctx, &pixels); + write(&format!("{name}-dn"), adapter, &pixels); } /// Linear 0..1 to 8-bit sRGB. @@ -67,11 +68,15 @@ fn srgb(x: f32) -> u8 { (v * 255.0).round() as u8 } -fn write(name: &str, ctx: &GpuContext, rgb: &[u8]) { +fn write(name: &str, adapter: &AdapterInfo, 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 device = if adapter.device_type == wgpu::DeviceType::Cpu { + "cpu" + } else { + "gpu" + }; let path = dir.join(format!("{name}-{device}.png")); image::save_buffer( &path, diff --git a/crates/resolved/tests/mesh.rs b/crates/resolved/tests/mesh.rs index 702f07f..a5f75d3 100644 --- a/crates/resolved/tests/mesh.rs +++ b/crates/resolved/tests/mesh.rs @@ -12,7 +12,7 @@ use resolved::gpu::bodies::{BodiesPass, BodiesUniforms, BodyRadiometry}; use resolved::gpu::mesh::{MeshDraw, MeshPass}; use resolved::gpu::targets::FrameTargets; use resolved::gpu::GpuContext; -use resolved::model::{LoadOptions, Material, Mesh, Part}; +use resolved::model::{LoadOptions, Material, Mesh, ModelId, Part}; use resolved::nalgebra::{UnitQuaternion, Vector3}; use resolved::radiometry::{Radiometry, AU}; use resolved::{CameraConfig, Pose, Scene, Target}; @@ -74,7 +74,13 @@ fn scene(sun: Vector3, target: Option) -> Scene { earth_attitude: UnitQuaternion::identity(), sun, moon: Vector3::new(0.0, -3.844e8, 0.0), - targets: target.into_iter().map(|pose| Target { pose }).collect(), + targets: target + .into_iter() + .map(|pose| Target { + model: ModelId(0), + pose, + }) + .collect(), } } @@ -115,7 +121,7 @@ fn render(ctx: &GpuContext, name: &str, scene: &Scene, camera: &Pose, mesh: &Mes meshes.encode(ctx, &mut encoder, &targets, &draws); ctx.queue.submit([encoder.finish()]); let radiance = targets.read_radiance(ctx); - common::save(name, ctx, &radiance); + common::save(name, &ctx.info, &radiance); Frame { radiance, depth: targets.read_depth(ctx), diff --git a/crates/resolved/tests/renderer.rs b/crates/resolved/tests/renderer.rs new file mode 100644 index 0000000..00c9bb5 --- /dev/null +++ b/crates/resolved/tests/renderer.rs @@ -0,0 +1,192 @@ +//! The public renderer API end to end, on the GPU and on the CPU (lavapipe). + +mod common; + +use std::io::Cursor; + +use resolved::gpu::AdapterChoice; +use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3}; +use resolved::radiometry::{Radiometry, AU}; +use resolved::{ + CameraConfig, LoadOptions, Material, Mesh, ModelId, Pose, Renderer, RendererOptions, Scene, + Target, +}; + +const ALBEDO: f64 = 0.5; + +/// A 2 m gray cube centered on its origin. +fn cube() -> Mesh { + let mut s = String::from("solid cube\n"); + let v = |i: usize| [i & 1, (i >> 1) & 1, (i >> 2) & 1].map(|b| 2.0 * b as f32 - 1.0); + for f in [ + [0, 2, 3, 1], + [4, 5, 7, 6], + [0, 1, 5, 4], + [2, 6, 7, 3], + [0, 4, 6, 2], + [1, 3, 7, 5], + ] { + 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 i in tri { + let [x, y, z] = v(i); + s += &format!("vertex {x} {y} {z}\n"); + } + s += "endloop\nendfacet\n"; + } + } + s += "endsolid cube\n"; + let options = LoadOptions { + default_material: Material::gray("gray", ALBEDO), + ..Default::default() + }; + Mesh::from_stl(&mut Cursor::new(s), "cube", &options).unwrap() +} + +fn renderers(read_radiance: bool) -> [Renderer; 2] { + [AdapterChoice::Gpu, AdapterChoice::Cpu].map(|adapter| { + let options = RendererOptions { + adapter, + read_radiance, + ..Default::default() + }; + Renderer::new(CameraConfig::example(), options).unwrap() + }) +} + +const TARGET: Vector3 = Vector3::new(7.0e6, 0.0, 0.0); +const SUN: Vector3 = Vector3::new(AU, 0.0, 0.0); + +fn scene(targets: Vec) -> Scene { + Scene { + earth_attitude: UnitQuaternion::identity(), + sun: SUN, + moon: Vector3::new(0.0, -3.844e8, 0.0), + targets, + } +} + +/// An observer 30 m sunward of the target, looking at it with Earth behind. +fn observer() -> Pose { + Pose::looking_at(TARGET + Vector3::new(30.0, 0.0, 0.0), TARGET, Vector3::z()).unwrap() +} + +fn cube_target(model: ModelId) -> Target { + Target { + model, + pose: Pose::new(TARGET, UnitQuaternion::identity()), + } +} + +#[test] +fn sunlit_cube_matches_the_cpu_chain() { + let config = CameraConfig::example(); + let r = Radiometry::new(&config); + let ray = config.intrinsics().ray(&Vector2::new(960.5, 540.5)); + for mut renderer in renderers(true) { + let model = renderer.load_model(&cube()).unwrap(); + let frame = renderer + .submit(&scene(vec![cube_target(model)]), &observer()) + .unwrap() + .wait() + .unwrap(); + common::save_dn("renderer-cube", renderer.adapter_info(), &frame.dn); + for c in 0..3 { + let radiance = r.lambert_radiance(c, ALBEDO, 1.0, (SUN - TARGET).norm()); + let want = config.detector.dn(r.electrons(radiance, ray.z)); + let got = frame.dn_at(960, 540, c as u32); + assert!( + got.abs_diff(want) <= 1, + "{} channel {c}: {got} vs {want}", + renderer.adapter_info().name + ); + let read = frame.radiance.as_ref().unwrap()[540 * 1920 + 960][c]; + assert!((read as f64 - radiance).abs() < 1e-4 * radiance); + } + } +} + +#[test] +fn camera_mount_is_rigid() { + // Mounting the camera rotated on the observer, and turning the observer the other + // way, must give the same image as no mount at all. + let turn = UnitQuaternion::from_euler_angles(0.3, -0.2, 1.1); + let plain = observer(); + let turned = Pose::new( + plain.position - plain.attitude * (turn.inverse() * Vector3::new(0.5, 0.0, 0.0)), + plain.attitude * turn.inverse(), + ); + for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] { + let mut frames = vec![]; + for (mount, observer) in [ + ( + Pose::new(Vector3::zeros(), UnitQuaternion::identity()), + plain, + ), + (Pose::new(Vector3::new(0.5, 0.0, 0.0), turn), turned), + ] { + let mut config = CameraConfig::example(); + config.mount = mount; + let options = RendererOptions { + adapter: adapter.clone(), + ..Default::default() + }; + let mut renderer = Renderer::new(config, options).unwrap(); + let model = renderer.load_model(&cube()).unwrap(); + frames.push( + renderer + .submit(&scene(vec![cube_target(model)]), &observer) + .unwrap() + .wait() + .unwrap(), + ); + } + let differing = frames[0] + .dn + .iter() + .zip(&frames[1].dn) + .filter(|(a, b)| a.abs_diff(**b) > 1) + .count(); + // Only pixels along the cube's silhouette may flip, from f32 rounding of the + // composed pose. + assert!(differing < 50, "{adapter:?}: {differing} values differ"); + } +} + +#[test] +fn frames_are_independent() { + for mut renderer in renderers(false) { + let model = renderer.load_model(&cube()).unwrap(); + let with = scene(vec![cube_target(model)]); + let without = scene(vec![]); + let cube_dn = renderer + .submit(&with, &observer()) + .unwrap() + .wait() + .unwrap() + .dn_at(960, 540, 1); + let earth_dn = renderer + .submit(&without, &observer()) + .unwrap() + .wait() + .unwrap() + .dn_at(960, 540, 1); + assert_ne!(cube_dn, earth_dn); + // A frame dropped without waiting must not disturb the next one. + drop(renderer.submit(&with, &observer()).unwrap()); + let again = renderer + .submit(&without, &observer()) + .unwrap() + .wait() + .unwrap(); + assert_eq!(again.dn_at(960, 540, 1), earth_dn); + } +} + +#[test] +fn unknown_model_is_an_error() { + let mut renderer = Renderer::new(CameraConfig::example(), Default::default()).unwrap(); + assert!(renderer + .submit(&scene(vec![cube_target(ModelId(0))]), &observer()) + .is_err()); +} diff --git a/crates/resolved/tests/sensor.rs b/crates/resolved/tests/sensor.rs index 08e979e..be45e2a 100644 --- a/crates/resolved/tests/sensor.rs +++ b/crates/resolved/tests/sensor.rs @@ -38,7 +38,7 @@ fn sense(ctx: &GpuContext, name: &str, radiance: &[[f32; 4]]) -> Vec { pass.encode(ctx, &mut encoder, &targets, &uniforms); ctx.queue.submit([encoder.finish()]); let dn = targets.read_dn(ctx); - common::save_dn(name, ctx, &dn); + common::save_dn(name, &ctx.info, &dn); dn } @@ -159,7 +159,7 @@ fn sunlit_earth_through_the_whole_chain() { 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); + common::save_dn("sensor-earth", &ctx.info, &dn); assert_eq!(dn[(540 * 1920 + 960) * 3 + 1], 732); assert_eq!(dn[0], 64, "space reads as the bias"); } diff --git a/docs/optics.md b/docs/optics.md index 7d91de1..2d610f0 100644 --- a/docs/optics.md +++ b/docs/optics.md @@ -494,7 +494,8 @@ satellites (Montenbruck and Gill), the same as astrokit's. A test checks it against tracing lines from the target to thousands of points spread over the solar disk. -The fraction is computed once per target, at its center. The whole target +The renderer counts both Earth and the Moon as occulters. The fraction is +computed once per target, at its center. The whole target dims together, which is accurate unless the target is a sizable fraction of the penumbra's width. Light reaching the target from Earth itself (Earthshine), which matters most when the target is in shadow, comes in M2. diff --git a/readme.md b/readme.md index 4dca84f..dc4f699 100644 --- a/readme.md +++ b/readme.md @@ -17,10 +17,54 @@ Rendering goes through wgpu. On a machine without a GPU, the same shaders run on the CPU through Mesa's lavapipe (`vulkan-swrast` on Arch, `mesa-vulkan-drivers` on Debian and Ubuntu). +## Usage + +```rust +use resolved::nalgebra::{UnitQuaternion, Vector3}; +use resolved::radiometry::AU; +use resolved::{CameraConfig, Mesh, Pose, Renderer, Scene, Target}; + +# fn main() -> Result<(), resolved::Error> { +// Setup, once: device, shaders, spectra folded into the camera's channels. +let mut renderer = Renderer::new(CameraConfig::example(), Default::default())?; + +// A model, uploaded once. Usually `Mesh::load(path, &options)` on an STL or glTF. +let stl = "solid panel +facet normal 0 0 0 +outer loop +vertex 0 -1 -1 +vertex 0 1 -1 +vertex 0 0 1 +endloop +endfacet +endsolid panel"; +let panel = renderer.load_model(&Mesh::from_stl(&mut std::io::Cursor::new(stl), "panel", &Default::default())?)?; + +// Each step: inertial positions in meters, Earth at the origin. +let target = Vector3::new(7.0e6, 0.0, 0.0); +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![Target { model: panel, pose: Pose::new(target, UnitQuaternion::identity()) }], +}; +let observer = Pose::looking_at(target + Vector3::new(20.0, 0.0, 0.0), target, Vector3::z()).unwrap(); + +let pending = renderer.submit(&scene, &observer)?; +// ... the simulation's own work for this step overlaps the GPU's here ... +let frame = pending.wait()?; + +// 12-bit DN, channels r, g, b interleaved. The panel faces the Sun and the camera. +assert!(frame.dn_at(960, 540, 1) > 1000); +# Ok(()) +# } +``` + ## Status -Early development. Nothing renders yet. The work is tracked as Gitea -milestones, each split into one issue per change: +M1 in progress: the library renders Earth, Moon, Sun and targets to DN +through `Renderer`; the CLI and benchmarks are still to come. The work is +tracked as Gitea milestones, each split into one issue per change: | Milestone | Scope | |---|---|