use std::f32::consts::PI;
use glam::Vec3;
#[path = "env_map_distribution.rs"]
mod env_map_distribution;
#[cfg(feature = "hdr")]
#[path = "env_map_hdr.rs"]
mod env_map_hdr;
#[path = "env_map_spectrum.rs"]
mod env_map_spectrum;
#[cfg(feature = "hdr")]
pub use env_map_hdr::{HdrLimits, environment_from_hdr_bytes};
#[cfg(feature = "gpu")]
pub(crate) use env_map_distribution::Distribution1D;
pub(crate) use env_map_distribution::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),
#[cfg(feature = "hdr")]
TooLarge {
width: u32,
height: u32,
limits: HdrLimits,
},
}
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}"),
#[cfg(feature = "hdr")]
Self::TooLarge {
width,
height,
limits,
} => write!(
f,
"HDR image {width}x{height} exceeds the decode limits ({}x{} texels, {} MiB decoded)",
limits.max_width,
limits.max_height,
limits.max_decoded_bytes / (1024 * 1024)
),
}
}
}
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> {
environment_from_hdr_bytes(bytes, HdrLimits::DEFAULT)
}
#[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 fn bitwise_eq(&self, other: &Self) -> bool {
self.width == other.width
&& self.height == other.height
&& self.pixels.len() == other.pixels.len()
&& self
.pixels
.iter()
.zip(&other.pixels)
.all(|(a, b)| env_map_distribution::bits_equal(a, b))
&& self.distribution.same_bits(&other.distribution)
}
#[must_use]
pub const fn width(&self) -> usize {
self.width
}
#[must_use]
pub const fn height(&self) -> usize {
self.height
}
#[must_use]
pub const fn texels(&self) -> &[[f32; 3]] {
self.pixels.as_slice()
}
#[cfg(feature = "gpu")]
#[must_use]
pub(crate) fn pixels(&self) -> &[[f32; 3]] {
&self.pixels
}
#[cfg(feature = "gpu")]
#[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 = (v.min(1.0 - v) * PI).sin();
let cos_theta = theta.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);
let d = dir.normalize_or_zero();
let sin_theta = d.x.hypot(d.z);
pdf_uv_to_solid_angle_from_sin(pdf_uv, sin_theta)
}
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 width_i = i64::try_from(self.width).expect("the texture width fits i64");
let height_i = i64::try_from(self.height).expect("the texture height fits i64");
let wrap_x = |x: i64| -> usize {
usize::try_from(x.rem_euclid(width_i)).expect("rem_euclid by the width is in 0..width")
};
let clamp_y = |y: i64| -> usize {
usize::try_from(y.clamp(0, height_i - 1)).expect("the row is clamped to 0..height")
};
#[expect(
clippy::cast_possible_truncation,
reason = "x0 and y0 are floors of finite texel coordinates within about +-width/height"
)]
let (x0_cell, y0_cell) = (x0 as i64, y0 as i64);
let x0i = wrap_x(x0_cell);
let x1i = wrap_x(x0_cell + 1);
let y0i = clamp_y(y0_cell);
let y1i = clamp_y(y0_cell + 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.min(1.0 - v) * PI;
pdf_uv_to_solid_angle_from_sin(pdf_uv, theta.sin())
}
fn pdf_uv_to_solid_angle_from_sin(pdf_uv: f32, sin_theta: f32) -> f32 {
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"
);
}
}
#[cfg(test)]
mod pole_numerics_tests {
use super::*;
const PI64: f64 = std::f64::consts::PI;
fn synthetic_map() -> EnvironmentMap {
let width = 16;
let height = 8;
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.9f32.mul_add(u, 0.05),
0.6f32.mul_add(v, 0.3),
(u * 3.0).sin().mul_add(0.2, 0.4f32.mul_add(v, 0.15)),
]
})
})
.collect();
EnvironmentMap::from_rgb(width, height, pixels).expect("self-consistent by construction")
}
fn luminance64(rgb: [f32; 3]) -> f64 {
0.0722f64.mul_add(
f64::from(rgb[2]),
0.7152f64.mul_add(f64::from(rgb[1]), 0.2126 * f64::from(rgb[0])),
)
}
fn weighted_texels_f64(map: &EnvironmentMap) -> (Vec<f64>, f64) {
let mut weighted = vec![0.0f64; map.width * map.height];
for y in 0..map.height {
let theta = (y as f64 + 0.5) / map.height as f64 * PI64;
let sin_theta = theta.sin().max(0.0);
for x in 0..map.width {
weighted[y * map.width + x] =
luminance64(map.pixels[y * map.width + x]) * sin_theta;
}
}
let total: f64 = weighted.iter().sum();
(weighted, total)
}
fn pdf_uv_f64(map: &EnvironmentMap, weighted: &[f64], total: f64, u: f64, v: f64) -> f64 {
let width = map.width;
let height = map.height;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamped fraction times the height is non-negative and below the height"
)]
let row = (v.clamp(0.0, 0.999_999_94) * height as f64) as usize;
let row = row.min(height - 1);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "the clamped fraction times the width is non-negative and below the width"
)]
let col = (u.clamp(0.0, 0.999_999_94) * width as f64) as usize;
let col = col.min(width - 1);
weighted[row * width + col] * width as f64 * height as f64 / total
}
fn pdf_solid_angle_f64(
map: &EnvironmentMap,
weighted: &[f64],
total: f64,
u: f64,
v: f64,
) -> f64 {
let theta = v * PI64;
let sin_theta = theta.sin();
pdf_uv_f64(map, weighted, total, u, v) / (2.0 * PI64 * PI64 * sin_theta)
}
#[test]
fn sample_pdf_matches_f64_reference_across_the_v_range() {
let map = synthetic_map();
let (weighted, total) = weighted_texels_f64(&map);
for u1 in [0.999_999f32, 0.9999, 0.999, 0.5, 0.001, 1e-6] {
for u0 in [0.13f32, 0.68] {
let (u, v, pdf_uv) = map.distribution.sample(u0, u1);
let (_dir, _rgb, pdf_sampled) = map.sample(u0, u1);
let pdf_uv_recomputed = map.distribution.pdf(u, v);
assert!(
(pdf_uv - pdf_uv_recomputed).abs() < 1e-4 * pdf_uv.max(1.0),
"test premise: distribution.sample/pdf must agree at (u={u}, v={v})"
);
let reference =
pdf_solid_angle_f64(&map, &weighted, total, f64::from(u), f64::from(v));
let rel_err = (f64::from(pdf_sampled) - reference).abs() / reference;
assert!(
rel_err < 1e-5,
"u0={u0}, u1={u1}: sample()'s pdf ({pdf_sampled}) must match the f64 \
reference ({reference}) to within 1e-5 relative (got {rel_err:e}), \
u={u}, v={v}"
);
}
}
}
#[test]
fn pdf_at_direction_matches_f64_reference_near_both_poles() {
let map = synthetic_map();
let (weighted, total) = weighted_texels_f64(&map);
let phis_deg = [10.0f64, 61.0, 137.0];
let angles_deg = [0.05f64, 0.5, 5.0];
for &angle_deg in &angles_deg {
for &phi_deg in &phis_deg {
for north_pole in [true, false] {
let angle_rad = angle_deg.to_radians();
let theta = if north_pole {
angle_rad
} else {
PI64 - angle_rad
};
let phi = phi_deg.to_radians();
let (sin_theta, cos_theta) = theta.sin_cos();
let (sin_phi, cos_phi) = phi.sin_cos();
let dir_f64 = (sin_theta * sin_phi, cos_theta, sin_theta * cos_phi);
#[expect(
clippy::cast_possible_truncation,
reason = "the production pdf takes an f32 direction; components are within -1..=1"
)]
let dir_f32 = Vec3::new(dir_f64.0 as f32, dir_f64.1 as f32, dir_f64.2 as f32);
let u_exact = (phi / (2.0 * PI64)).rem_euclid(1.0);
let v_exact = theta / PI64;
let reference = pdf_solid_angle_f64(&map, &weighted, total, u_exact, v_exact);
let actual = map.pdf(dir_f32);
let rel_err = (f64::from(actual) - reference).abs() / reference;
assert!(
rel_err < 1e-5,
"angle_deg={angle_deg} off the {} pole, phi_deg={phi_deg}: \
pdf() ({actual}) must match the f64 reference ({reference}) to \
within 1e-5 relative (got {rel_err:e}), dir={dir_f32:?}",
if north_pole { "north" } else { "south" }
);
}
}
}
}
#[test]
fn sample_and_pdf_agree_at_sampled_direction_within_texel_of_either_pole() {
let map = synthetic_map();
for u1 in [1e-7f32, 1e-6, 1e-5, 1.0 - 1e-6, 1.0 - 1e-7] {
for u0 in [0.21f32, 0.77] {
let (dir, _rgb, pdf_sampled) = map.sample(u0, u1);
let pdf_looked_up = map.pdf(dir);
let rel_err =
(pdf_sampled - pdf_looked_up).abs() / pdf_sampled.max(pdf_looked_up).max(1e-8);
assert!(
rel_err < 1e-4,
"u0={u0}, u1={u1}: sample()'s pdf ({pdf_sampled}) and pdf() at the \
sampled direction ({pdf_looked_up}) must agree to within 1e-4 \
relative near the poles (got {rel_err:e}), dir={dir:?}"
);
}
}
}
}