use std::f32::consts::PI;
use glam::Vec3;
#[path = "env_map_distribution.rs"]
mod env_map_distribution;
#[path = "env_map_spectrum.rs"]
mod env_map_spectrum;
pub(crate) use env_map_distribution::{Distribution1D, Distribution2D};
pub use env_map_spectrum::rgb_to_spectral_radiance;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvMapError {
DimensionMismatch {
width: usize,
height: usize,
len: usize,
},
ZeroSized,
#[cfg(feature = "hdr")]
Decode(String),
}
impl std::fmt::Display for EnvMapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DimensionMismatch { width, height, len } => {
write!(
f,
"environment map pixel buffer has {len} texels, expected width*height = {width}*{height} = {}",
width * height
)
}
Self::ZeroSized => write!(f, "environment map width and height must both be non-zero"),
#[cfg(feature = "hdr")]
Self::Decode(msg) => write!(f, "failed to decode HDR image: {msg}"),
}
}
}
impl std::error::Error for EnvMapError {}
#[derive(Debug, Clone)]
pub struct EnvironmentMap {
width: usize,
height: usize,
pixels: Vec<[f32; 3]>,
distribution: Distribution2D,
}
impl EnvironmentMap {
pub fn from_rgb(
width: usize,
height: usize,
pixels: Vec<[f32; 3]>,
) -> Result<Self, EnvMapError> {
if width == 0 || height == 0 {
return Err(EnvMapError::ZeroSized);
}
if pixels.len() != width * height {
return Err(EnvMapError::DimensionMismatch {
width,
height,
len: pixels.len(),
});
}
let distribution = build_distribution(&pixels, width, height);
Ok(Self {
width,
height,
pixels,
distribution,
})
}
#[must_use]
pub fn uniform(width: usize, height: usize, radiance: [f32; 3]) -> Self {
let width = width.max(1);
let height = height.max(1);
Self::from_rgb(width, height, vec![radiance; width * height])
.expect("uniform() constructs a self-consistent buffer")
}
#[cfg(feature = "hdr")]
pub fn from_hdr_bytes(bytes: &[u8]) -> Result<Self, EnvMapError> {
let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Hdr)
.map_err(|e| EnvMapError::Decode(e.to_string()))?;
let rgb = decoded.into_rgb32f();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
let pixels: Vec<[f32; 3]> = rgb.pixels().map(|p| p.0).collect();
Self::from_rgb(width, height, pixels)
}
#[cfg(feature = "hdr")]
pub fn from_hdr_file(path: impl AsRef<std::path::Path>) -> Result<Self, EnvMapError> {
let bytes = std::fs::read(path).map_err(|e| EnvMapError::Decode(e.to_string()))?;
Self::from_hdr_bytes(&bytes)
}
#[must_use]
pub const fn width(&self) -> usize {
self.width
}
#[must_use]
pub const fn height(&self) -> usize {
self.height
}
#[must_use]
pub(crate) fn pixels(&self) -> &[[f32; 3]] {
&self.pixels
}
#[must_use]
pub(crate) const fn distribution(&self) -> &Distribution2D {
&self.distribution
}
#[must_use]
pub fn uv_to_direction(u: f32, v: f32) -> Vec3 {
let u = u.rem_euclid(1.0);
let v = v.clamp(0.0, 1.0);
let theta = v * PI;
let phi = u * 2.0 * PI;
let (sin_theta, cos_theta) = theta.sin_cos();
let (sin_phi, cos_phi) = phi.sin_cos();
Vec3::new(sin_theta * sin_phi, cos_theta, sin_theta * cos_phi)
}
#[must_use]
pub fn direction_to_uv(dir: Vec3) -> (f32, f32) {
let d = dir.normalize_or_zero();
let theta = d.y.clamp(-1.0, 1.0).acos();
let phi = d.x.atan2(d.z);
let v = theta / PI;
let u = (phi / (2.0 * PI)).rem_euclid(1.0);
(u, v)
}
#[must_use]
pub fn radiance_rgb(&self, dir: Vec3) -> [f32; 3] {
let (u, v) = Self::direction_to_uv(dir);
self.sample_bilinear(u, v)
}
#[must_use]
pub fn radiance_at(&self, dir: Vec3, lambda_nm: f32) -> f32 {
rgb_to_spectral_radiance(self.radiance_rgb(dir), lambda_nm)
}
#[must_use]
pub fn sample(&self, u0: f32, u1: f32) -> (Vec3, [f32; 3], f32) {
let (u, v, pdf_uv) = self.distribution.sample(u0, u1);
let dir = Self::uv_to_direction(u, v);
let rgb = self.sample_bilinear(u, v);
let pdf = pdf_uv_to_solid_angle(pdf_uv, v);
(dir, rgb, pdf)
}
#[must_use]
pub fn pdf(&self, dir: Vec3) -> f32 {
let (u, v) = Self::direction_to_uv(dir);
let pdf_uv = self.distribution.pdf(u, v);
pdf_uv_to_solid_angle(pdf_uv, v)
}
fn sample_bilinear(&self, u: f32, v: f32) -> [f32; 3] {
if self.width == 1 && self.height == 1 {
return self.pixels[0];
}
let fx = u.rem_euclid(1.0).mul_add(self.width as f32, -0.5);
let fy = v.clamp(0.0, 1.0).mul_add(self.height as f32, -0.5);
let x0 = fx.floor();
let y0 = fy.floor();
let tx = fx - x0;
let ty = fy - y0;
let wrap_x = |x: i64| -> usize { x.rem_euclid(self.width as i64) as usize };
let clamp_y = |y: i64| -> usize { y.clamp(0, self.height as i64 - 1) as usize };
let x0i = wrap_x(x0 as i64);
let x1i = wrap_x(x0 as i64 + 1);
let y0i = clamp_y(y0 as i64);
let y1i = clamp_y(y0 as i64 + 1);
let p00 = self.texel(x0i, y0i);
let p10 = self.texel(x1i, y0i);
let p01 = self.texel(x0i, y1i);
let p11 = self.texel(x1i, y1i);
let mut out = [0.0f32; 3];
for c in 0..3 {
let top = p10[c].mul_add(tx, p00[c] * (1.0 - tx));
let bottom = p11[c].mul_add(tx, p01[c] * (1.0 - tx));
out[c] = bottom.mul_add(ty, top * (1.0 - ty));
}
out
}
fn texel(&self, x: usize, y: usize) -> [f32; 3] {
self.pixels[y * self.width + x]
}
}
fn pdf_uv_to_solid_angle(pdf_uv: f32, v: f32) -> f32 {
let theta = v * PI;
let sin_theta = theta.sin();
if sin_theta <= 1e-6 {
return 0.0;
}
pdf_uv / (2.0 * PI * PI * sin_theta)
}
fn luminance(rgb: [f32; 3]) -> f32 {
0.0722f32.mul_add(rgb[2], 0.7152f32.mul_add(rgb[1], 0.2126 * rgb[0]))
}
fn build_distribution(pixels: &[[f32; 3]], width: usize, height: usize) -> Distribution2D {
let mut weighted = Vec::with_capacity(width * height);
for y in 0..height {
let theta = (y as f32 + 0.5) / height as f32 * PI;
let sin_theta = theta.sin().max(0.0);
for x in 0..width {
weighted.push(luminance(pixels[y * width + x]) * sin_theta);
}
}
Distribution2D::new(&weighted, width, height)
}
#[cfg(test)]
mod nee_sample_pdf_tests {
use super::*;
fn synthetic_map() -> EnvironmentMap {
let width = 12;
let height = 6;
let pixels: Vec<[f32; 3]> = (0..height)
.flat_map(|y| {
(0..width).map(move |x| {
let u = x as f32 / width as f32;
let v = y as f32 / height as f32;
[0.1 + u, 0.2 + v, u.mul_add(v, 0.05)]
})
})
.collect();
EnvironmentMap::from_rgb(width, height, pixels).expect("self-consistent by construction")
}
#[test]
fn sample_pdf_matches_independently_evaluated_pdf_in_solid_angle_measure() {
let map = synthetic_map();
let mut checked = 0u32;
for i in 0..37u32 {
for j in 0..29u32 {
let u0 = (i as f32 + 0.5) / 37.0;
let u1 = (j as f32 + 0.5) / 29.0;
let (dir, _rgb, pdf_sampled) = map.sample(u0, u1);
let pdf_looked_up = map.pdf(dir);
assert!(
(pdf_sampled - pdf_looked_up).abs() < 1e-3 * pdf_sampled.max(1.0),
"sample()'s own pdf ({pdf_sampled}) must match pdf() evaluated \
independently at the sampled direction ({pdf_looked_up}) for \
u0={u0}, u1={u1}, dir={dir:?}"
);
checked += 1;
}
}
assert!(
checked > 900,
"sanity: should have checked ~1000 (u0,u1) pairs"
);
}
}