diff --git a/clovers-cli/Cargo.toml b/clovers-cli/Cargo.toml index 61ec8ebf..6e595305 100644 --- a/clovers-cli/Cargo.toml +++ b/clovers-cli/Cargo.toml @@ -9,9 +9,8 @@ name = "clovers-cli" path = "src/main.rs" [lib] -# For testing purposes -name = "clovers_draw_cpu" -path = "src/draw_cpu.rs" +name = "clovers_runtime" +path = "src/lib.rs" [dependencies] # Internal @@ -23,6 +22,7 @@ clovers = { path = "../clovers", features = [ ], default-features = false } # External +blue-noise-sampler = "0.1.0" clap = { version = "4.5.1", features = ["std", "derive"] } human_format = "1.1.0" humantime = "2.1.0" @@ -32,6 +32,7 @@ indicatif = { version = "0.17.8", features = [ "rayon", ], default-features = false } palette = { version = "0.7.5", features = ["serializing"] } +paste = { version = "1.0.14" } rand = { version = "0.8.5", features = ["small_rng"], default-features = false } rayon = "1.9.0" serde = { version = "1.0.197", features = ["derive"], default-features = false } diff --git a/clovers-cli/benches/draw_cpu.rs b/clovers-cli/benches/draw_cpu.rs index f6b642f1..2f6ea93a 100644 --- a/clovers-cli/benches/draw_cpu.rs +++ b/clovers-cli/benches/draw_cpu.rs @@ -1,6 +1,7 @@ use clovers::scenes::{initialize, Scene, SceneFile}; use clovers::RenderOpts; -use clovers_draw_cpu::draw; +use clovers_runtime::draw_cpu::draw; +use clovers_runtime::sampler::Sampler; use divan::{black_box, AllocProfiler}; #[global_allocator] @@ -26,7 +27,7 @@ fn draw_cornell(bencher: divan::Bencher) { bencher .with_inputs(get_cornell) .counter(1u32) - .bench_values(|scene| black_box(draw(OPTS, &scene))) + .bench_values(|scene| black_box(draw(OPTS, &scene, Sampler::Random))) } fn get_cornell<'scene>() -> Scene<'scene> { diff --git a/clovers-cli/src/draw_cpu.rs b/clovers-cli/src/draw_cpu.rs index ee5bae51..78a5bc46 100644 --- a/clovers-cli/src/draw_cpu.rs +++ b/clovers-cli/src/draw_cpu.rs @@ -1,3 +1,5 @@ +use clovers::wavelength::random_wavelength; +use clovers::Vec2; use clovers::{ colorize::colorize, normals::normal_map, ray::Ray, scenes::Scene, Float, RenderOpts, }; @@ -10,8 +12,12 @@ use rand::rngs::SmallRng; use rand::{Rng, SeedableRng}; use rayon::prelude::*; +use crate::sampler::blue::BlueSampler; +use crate::sampler::random::RandomSampler; +use crate::sampler::{Sample, Sampler, SamplerTrait}; + /// The main drawing function, returns a `Vec` as a pixelbuffer. -pub fn draw(opts: RenderOpts, scene: &Scene) -> Vec> { +pub fn draw(opts: RenderOpts, scene: &Scene, sampler: Sampler) -> Vec> { let width = opts.width as usize; let height = opts.height as usize; let bar = progress_bar(&opts); @@ -19,14 +25,21 @@ pub fn draw(opts: RenderOpts, scene: &Scene) -> Vec> { let pixelbuffer: Vec> = (0..height) .into_par_iter() .map(|row_index| { + let mut sampler_rng = SmallRng::from_entropy(); + let mut sampler: Box = match sampler { + Sampler::Blue => Box::new(BlueSampler::new(&opts)), + Sampler::Random => Box::new(RandomSampler::new(&mut sampler_rng)), + }; + let mut rng = SmallRng::from_entropy(); let mut row = Vec::with_capacity(width); + for index in 0..width { let index = index + row_index * width; if opts.normalmap { row.push(render_pixel_normalmap(scene, &opts, index, &mut rng)); } else { - row.push(render_pixel(scene, &opts, index, &mut rng)); + row.push(render_pixel(scene, &opts, index, &mut rng, &mut *sampler)); } } bar.inc(1); @@ -39,16 +52,40 @@ pub fn draw(opts: RenderOpts, scene: &Scene) -> Vec> { } // Render a single pixel, including possible multisampling -fn render_pixel(scene: &Scene, opts: &RenderOpts, index: usize, rng: &mut SmallRng) -> Srgb { +fn render_pixel( + scene: &Scene, + opts: &RenderOpts, + index: usize, + rng: &mut SmallRng, + sampler: &mut dyn SamplerTrait, +) -> Srgb { let (x, y, width, height) = index_to_params(opts, index); - let mut color: Xyz = Xyz::new(0.0, 0.0, 0.0); - for _sample in 0..opts.samples { - if let Some(s) = sample(scene, x, y, width, height, rng, opts.max_depth) { - color += s + let pixel_location = Vec2::new(x, y); + let canvas_size = Vec2::new(width, height); + let max_depth = opts.max_depth; + let mut pixel_color: Xyz = Xyz::new(0.0, 0.0, 0.0); + for sample in 0..opts.samples { + let Sample { + pixel_offset, + lens_offset, + time, + wavelength, + } = sampler.sample(x as i32, y as i32, sample as i32); + let pixel_uv: Vec2 = Vec2::new( + (pixel_location.x + pixel_offset.x) / canvas_size.x, + (pixel_location.y + pixel_offset.y) / canvas_size.y, + ); + // note get_ray wants uv 0..1 location + let ray: Ray = scene + .camera + .get_ray(pixel_uv, lens_offset, time, wavelength); + let sample_color: Xyz = colorize(&ray, scene, 0, max_depth, rng); + if sample_color.x.is_finite() && sample_color.y.is_finite() && sample_color.z.is_finite() { + pixel_color += sample_color; } } - color /= opts.samples as Float; - let color: Srgb = color.adapt_into(); + pixel_color /= opts.samples as Float; + let color: Srgb = pixel_color.adapt_into(); let color: Srgb = color.into_format(); color } @@ -61,47 +98,21 @@ fn render_pixel_normalmap( rng: &mut SmallRng, ) -> Srgb { let (x, y, width, height) = index_to_params(opts, index); - let color: LinSrgb = sample_normalmap(scene, x, y, width, height, rng); + let color: LinSrgb = { + let pixel_location = Vec2::new(x / width, y / height); + let lens_offset = Vec2::new(0.0, 0.0); + let wavelength = random_wavelength(rng); + let time = rng.gen(); + let ray: Ray = scene + .camera + .get_ray(pixel_location, lens_offset, time, wavelength); + normal_map(&ray, scene, rng) + }; let color: Srgb = color.into_color_unclamped(); let color: Srgb = color.into_format(); color } -// Get a single sample for a single pixel in the scene, normalmap mode. -fn sample_normalmap( - scene: &Scene, - x: Float, - y: Float, - width: Float, - height: Float, - rng: &mut SmallRng, -) -> LinSrgb { - let u = x / width; - let v = y / height; - let ray: Ray = scene.camera.get_ray(u, v, rng); - normal_map(&ray, scene, rng) -} - -/// Get a single sample for a single pixel in the scene. Has slight jitter for antialiasing when multisampling. -fn sample( - scene: &Scene, - x: Float, - y: Float, - width: Float, - height: Float, - rng: &mut SmallRng, - max_depth: u32, -) -> Option> { - let u = (x + rng.gen::()) / width; - let v = (y + rng.gen::()) / height; - let ray: Ray = scene.camera.get_ray(u, v, rng); - let color: Xyz = colorize(&ray, scene, 0, max_depth, rng); - if color.x.is_finite() && color.y.is_finite() && color.z.is_finite() { - return Some(color); - } - None -} - fn index_to_params(opts: &RenderOpts, index: usize) -> (Float, Float, Float, Float) { let x = (index % (opts.width as usize)) as Float; let y = (index / (opts.width as usize)) as Float; diff --git a/clovers-cli/src/lib.rs b/clovers-cli/src/lib.rs new file mode 100644 index 00000000..d7f8f44b --- /dev/null +++ b/clovers-cli/src/lib.rs @@ -0,0 +1,2 @@ +pub mod draw_cpu; +pub mod sampler; diff --git a/clovers-cli/src/main.rs b/clovers-cli/src/main.rs index 1aa02247..a89d712f 100644 --- a/clovers-cli/src/main.rs +++ b/clovers-cli/src/main.rs @@ -21,6 +21,8 @@ use tracing_subscriber::fmt::time::UtcTime; use clovers::*; mod draw_cpu; mod json_scene; +mod sampler; +use sampler::Sampler; // Configure CLI parameters #[derive(Parser)] @@ -56,6 +58,9 @@ struct Opts { /// Render a normal map only. Experimental feature. #[clap(long)] normalmap: bool, + /// Sampler to use for rendering. Experimental feature. + #[clap(long, default_value = "random")] + sampler: Sampler, } fn main() -> Result<(), Box> { @@ -70,6 +75,7 @@ fn main() -> Result<(), Box> { gpu, debug, normalmap, + sampler, } = Opts::parse(); if debug { @@ -91,10 +97,15 @@ fn main() -> Result<(), Box> { println!(); println!("{width}x{height} resolution"); println!("{samples} samples per pixel"); + println!("using the {sampler} sampler"); println!("{max_depth} max bounce depth"); println!(); // Empty line before progress bar } + if sampler == Sampler::Blue && !([1, 2, 4, 8, 16, 32, 128, 256].contains(&samples)) { + panic!("the blue sampler only supports the following sample-per-pixel counts: [1, 2, 4, 8, 16, 32, 128, 256]"); + } + let renderopts: RenderOpts = RenderOpts { width, height, @@ -119,7 +130,7 @@ fn main() -> Result<(), Box> { let start = Instant::now(); let pixelbuffer = match gpu { // Note: live progress bar printed within draw_cpu::draw - false => draw_cpu::draw(renderopts, &scene), + false => draw_cpu::draw(renderopts, &scene, sampler), true => unimplemented!("GPU accelerated rendering is currently unimplemented"), }; info!("Drawing a pixelbuffer finished"); diff --git a/clovers-cli/src/sampler.rs b/clovers-cli/src/sampler.rs new file mode 100644 index 00000000..f57b27e9 --- /dev/null +++ b/clovers-cli/src/sampler.rs @@ -0,0 +1,40 @@ +use std::fmt::Display; + +use clap::ValueEnum; +use clovers::{wavelength::Wavelength, Float, Vec2}; + +pub mod blue; +pub mod random; + +pub trait SamplerTrait<'scene> { + // TODO: better types + fn sample(&mut self, i: i32, j: i32, index: i32) -> Sample; +} + +/// A collection of random values to be used for each sample. Returned as a struct to ensure the correct sampling order for the underlying source of randomness. +pub struct Sample { + /// Intra-pixel x,y offset. Used for antialiasing. + pub pixel_offset: Vec2, + /// The x,y offset used in the lens equations for aperture / depth-of-field simulation + pub lens_offset: Vec2, + /// The time of the ray, in range 0..1 + pub time: Float, + /// Wavelength of the ray + pub wavelength: Wavelength, +} + +#[derive(Clone, Debug, PartialEq, ValueEnum)] +pub enum Sampler { + Blue, + Random, +} + +impl Display for Sampler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Sampler::Blue => "blue", + Sampler::Random => "random", + }; + write!(f, "{s}") + } +} diff --git a/clovers-cli/src/sampler/blue.rs b/clovers-cli/src/sampler/blue.rs new file mode 100644 index 00000000..8c41fc86 --- /dev/null +++ b/clovers-cli/src/sampler/blue.rs @@ -0,0 +1,96 @@ +use clovers::{wavelength::sample_wavelength, Float, RenderOpts, Vec2}; + +use super::{Sample, SamplerTrait}; + +pub struct BlueSampler { + get: fn(i32, i32, i32, i32) -> Float, +} + +impl<'scene> BlueSampler { + pub fn new(opts: &'scene RenderOpts) -> Self { + let get = match opts.samples { + 1 => blue_sample_spp1, + 2 => blue_sample_spp2, + 4 => blue_sample_spp4, + 8 => blue_sample_spp8, + 16 => blue_sample_spp16, + 32 => blue_sample_spp32, + 64 => blue_sample_spp64, + 128 => blue_sample_spp128, + 256 => blue_sample_spp256, + _ => unimplemented!( + "blue sampler only supports sample-per-pixel counts that are powers of two of and up to 256" + ), + }; + Self { get } + } +} + +impl<'scene> SamplerTrait<'scene> for BlueSampler { + fn sample(&mut self, i: i32, j: i32, index: i32) -> Sample { + let pixel_offset = Vec2::new((self.get)(i, j, index, 0), (self.get)(i, j, index, 1)); + let lens_offset = Vec2::new((self.get)(i, j, index, 2), (self.get)(i, j, index, 3)); + let time = (self.get)(i, j, index, 4); + // TODO: verify uniformity & correctness for math? + let wavelength = sample_wavelength((self.get)(i, j, index, 5)); + + Sample { + pixel_offset, + lens_offset, + time, + wavelength, + } + } +} + +macro_rules! define_blue_sampler { + ($spp:ident) => { + ::paste::paste! { + pub fn []( + mut pixel_i: i32, + mut pixel_j: i32, + mut sample_index: i32, + mut sample_dimension: i32) -> Float { + use blue_noise_sampler::$spp::*; + + // Adapted from and + + // wrap arguments + pixel_i &= 127; + pixel_j &= 127; + sample_index &= 255; + sample_dimension &= 255; + + // xor index based on optimized ranking + // jb: 1spp blue noise has all 0 in g_blueNoiseRankingTile so we can skip the load + let index = sample_dimension + (pixel_i + pixel_j * 128) * 8; + let index = index as usize; + let ranked_sample_index = sample_index ^ RANKING_TILE[index]; + + // fetch value in sequence + let index = sample_dimension + ranked_sample_index * 256; + let index = index as usize; + let value = SOBOL[index]; + + // If the dimension is optimized, xor sequence value based on optimized scrambling + let index = (sample_dimension % 8) + (pixel_i + pixel_j * 128) * 8; + let index = index as usize; + let value = value ^ SCRAMBLING_TILE[index]; + + // convert to float and return + let v: Float = (0.5 + value as Float) / 256.0; + v + } + } + }; +} + +define_blue_sampler!(spp1); +define_blue_sampler!(spp2); +define_blue_sampler!(spp4); +define_blue_sampler!(spp8); +define_blue_sampler!(spp16); +define_blue_sampler!(spp32); +define_blue_sampler!(spp64); +define_blue_sampler!(spp128); +define_blue_sampler!(spp256); diff --git a/clovers-cli/src/sampler/random.rs b/clovers-cli/src/sampler/random.rs new file mode 100644 index 00000000..9ae0a671 --- /dev/null +++ b/clovers-cli/src/sampler/random.rs @@ -0,0 +1,31 @@ +use clovers::{random::random_in_unit_disk, wavelength::random_wavelength, Vec2}; +use rand::{rngs::SmallRng, Rng}; + +use super::{Sample, SamplerTrait}; + +#[derive(Debug)] +pub struct RandomSampler<'scene> { + rng: &'scene mut SmallRng, +} + +impl<'scene> RandomSampler<'scene> { + pub fn new(rng: &'scene mut SmallRng) -> Self { + Self { rng } + } +} + +impl<'scene> SamplerTrait<'scene> for RandomSampler<'scene> { + fn sample(&mut self, _i: i32, _j: i32, _index: i32) -> Sample { + let pixel_offset = Vec2::new(self.rng.gen(), self.rng.gen()); + let lens_offset = random_in_unit_disk(self.rng).xy(); + let time = self.rng.gen(); + let wavelength = random_wavelength(self.rng); + + Sample { + pixel_offset, + lens_offset, + time, + wavelength, + } + } +} diff --git a/clovers/src/camera.rs b/clovers/src/camera.rs index 5ba6befe..20002860 100644 --- a/clovers/src/camera.rs +++ b/clovers/src/camera.rs @@ -2,12 +2,10 @@ #![allow(clippy::too_many_arguments)] // TODO: Camera::new() has a lot of arguments. -use crate::wavelength::random_wavelength; -use crate::{random::random_in_unit_disk, ray::Ray, Float, Vec3, PI}; -use crate::{Direction, Position}; +use crate::wavelength::Wavelength; +use crate::{ray::Ray, Float, Vec3, PI}; +use crate::{Direction, Position, Vec2}; use nalgebra::Unit; -use rand::rngs::SmallRng; -use rand::Rng; #[derive(Copy, Clone, Debug)] #[cfg_attr(feature = "serde-derive", derive(serde::Serialize, serde::Deserialize))] @@ -100,19 +98,24 @@ impl Camera { } } - // TODO: fix the mysterious (u,v) vs (s,t) change that came from the tutorial - /// Generates a new [Ray] from the camera, at a random location of the aperture, at a random time interval between `time_0`, `time_1` of the camera. + /// Generates a new [Ray] from the camera #[must_use] - pub fn get_ray(self, s: Float, t: Float, rng: &mut SmallRng) -> Ray { + pub fn get_ray( + self, + pixel_uv: Vec2, // pixel location in image uv coordinates, range 0..1 + mut lens_offset: Vec2, + time: Float, + wavelength: Wavelength, + ) -> Ray { + let (x_offset, y_offset) = (pixel_uv.x, pixel_uv.y); // TODO: add a better defocus blur / depth of field implementation - let rd: Vec3 = self.lens_radius * random_in_unit_disk(rng); - let offset: Vec3 = *self.u * rd.x + *self.v * rd.y; - // Randomized time used for motion blur - let time: Float = rng.gen_range(self.time_0..self.time_1); - // Random wavelength for spectral rendering - let wavelength = random_wavelength(rng); + lens_offset.x *= &self.lens_radius; + lens_offset.y *= &self.lens_radius; + let offset: Vec3 = *self.u * lens_offset.x + *self.v * lens_offset.y; let direction = - self.lower_left_corner + s * self.horizontal + t * self.vertical - self.origin - offset; + self.lower_left_corner + x_offset * self.horizontal + y_offset * self.vertical + - self.origin + - offset; let direction = Unit::new_normalize(direction); Ray { origin: self.origin + offset, diff --git a/clovers/src/wavelength.rs b/clovers/src/wavelength.rs index 4c799347..75cb1f56 100644 --- a/clovers/src/wavelength.rs +++ b/clovers/src/wavelength.rs @@ -29,6 +29,19 @@ pub fn random_wavelength(rng: &mut SmallRng) -> Wavelength { SPECTRUM.sample_single(rng) } +// TODO: clippy fixes possible? +/// Given a sample seed from a sampler, return the approximate wavelenght. +#[must_use] +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::cast_sign_loss)] +#[allow(clippy::cast_precision_loss)] +pub fn sample_wavelength(sample: Float) -> Wavelength { + let pick = (sample * SPECTRUM_SIZE as Float).floor() as usize + MIN_WAVELENGTH; + assert!(pick <= MAX_WAVELENGTH); + assert!(pick >= MIN_WAVELENGTH); + pick +} + /// Given a hero wavelength, create additional equidistant wavelengths in the visible spectrum. Returns an array of wavelengths, with the original hero wavelength as the first one. #[must_use] pub fn rotate_wavelength(hero: Wavelength) -> [Wavelength; WAVE_SAMPLE_COUNT] {