use crate::{
color::{ColorSpace, ToneMap},
optics::raytracer::xyz_to_srgb_gamma,
};
use glam::Vec3;
#[cfg(target_arch = "wasm32")]
#[must_use]
pub const fn effective_thread_count(_threads: usize) -> usize {
1
}
#[cfg(not(target_arch = "wasm32"))]
#[must_use]
pub fn effective_thread_count(threads: usize) -> usize {
if threads == 0 {
std::thread::available_parallelism().map_or(8, std::num::NonZero::get)
} else {
threads
}
}
fn tonemap_chunk(colors: &[Vec3], dst: &mut [u8], scale: f32) {
debug_assert_eq!(dst.len(), colors.len() * 4);
for (i, xyz) in colors.iter().enumerate() {
let rgba = xyz_to_srgb_gamma(*xyz * scale);
let p = i * 4;
dst[p] = rgba[0];
dst[p + 1] = rgba[1];
dst[p + 2] = rgba[2];
dst[p + 3] = rgba[3];
}
}
pub fn tonemap_to_rgba_with_threads_into(
colors: &[Vec3],
scale: f32,
threads: usize,
out: &mut [u8],
) {
let pixels = colors.len().min(out.len() / 4);
let colors = &colors[..pixels];
let out = &mut out[..pixels * 4];
if colors.is_empty() {
return;
}
let num_threads = effective_thread_count(threads).max(1);
let chunk_len = pixels.div_ceil(num_threads).max(1);
if chunk_len >= pixels {
tonemap_chunk(colors, out, scale);
return;
}
std::thread::scope(|s| {
let color_chunks = colors.chunks(chunk_len);
let byte_chunks = out.chunks_mut(chunk_len * 4);
for (color_chunk, byte_chunk) in color_chunks.zip(byte_chunks) {
s.spawn(move || tonemap_chunk(color_chunk, byte_chunk, scale));
}
});
}
pub fn tonemap_to_rgba_into(colors: &[Vec3], scale: f32, out: &mut [u8]) {
tonemap_to_rgba_with_threads_into(colors, scale, 0, out);
}
#[must_use]
pub fn tonemap_to_rgba_with_threads(colors: &[Vec3], scale: f32, threads: usize) -> Vec<u8> {
let mut out = vec![0u8; colors.len() * 4];
tonemap_to_rgba_with_threads_into(colors, scale, threads, &mut out);
out
}
#[must_use]
pub fn tonemap_to_rgba(colors: &[Vec3], scale: f32) -> Vec<u8> {
tonemap_to_rgba_with_threads(colors, scale, 0)
}
fn tonemap_chunk_in(colors: &[Vec3], dst: &mut [u8], scale: f32, color_space: ColorSpace) {
debug_assert_eq!(dst.len(), colors.len() * 4);
for (i, xyz) in colors.iter().enumerate() {
let rgba = color_space.encode(*xyz * scale, ToneMap::AcesFilmic { exposure: 1.0 });
dst[i * 4..i * 4 + 4].copy_from_slice(&rgba);
}
}
#[must_use]
pub fn tonemap_wide_gamut_with_threads(
colors: &[Vec3],
scale: f32,
color_space: ColorSpace,
threads: usize,
) -> Vec<u8> {
let mut out = vec![0u8; colors.len() * 4];
if colors.is_empty() {
return out;
}
let num_threads = effective_thread_count(threads).max(1);
let chunk_len = colors.len().div_ceil(num_threads).max(1);
if chunk_len >= colors.len() {
tonemap_chunk_in(colors, &mut out, scale, color_space);
return out;
}
std::thread::scope(|s| {
let color_chunks = colors.chunks(chunk_len);
let byte_chunks = out.chunks_mut(chunk_len * 4);
for (color_chunk, byte_chunk) in color_chunks.zip(byte_chunks) {
s.spawn(move || tonemap_chunk_in(color_chunk, byte_chunk, scale, color_space));
}
});
out
}
#[must_use]
pub fn tonemap_accumulation(
width: u32,
height: u32,
samples_per_pixel: u32,
accum: &[Vec3],
color_space: ColorSpace,
) -> Vec<u8> {
debug_assert_eq!(
accum.len(),
(width as usize) * (height as usize),
"accum must hold exactly width*height pixels"
);
let inv_samples = 1.0 / samples_per_pixel as f32;
if color_space == ColorSpace::Srgb {
tonemap_to_rgba(accum, inv_samples)
} else {
tonemap_wide_gamut_with_threads(accum, inv_samples, color_space, 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Xorshift32(u32);
impl Xorshift32 {
const fn new(seed: u32) -> Self {
Self(if seed == 0 { 0xdead_beef } else { seed })
}
const fn next_u32(&mut self) -> u32 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
self.0 = x;
x
}
fn next_f32(&mut self) -> f32 {
(f64::from(self.next_u32()) / f64::from(u32::MAX)) as f32
}
}
fn irregular_colors(len: usize, seed: u32) -> Vec<Vec3> {
let mut rng = Xorshift32::new(seed);
(0..len)
.map(|_| {
Vec3::new(
rng.next_f32() * 4.0,
rng.next_f32() * 4.0,
rng.next_f32() * 4.0,
)
})
.collect()
}
#[test]
fn tonemap_is_thread_count_invariant() {
let colors = irregular_colors(10_007, 0x1234_5678);
let scale = 0.37;
let reference = tonemap_to_rgba_with_threads(&colors, scale, 1);
for threads in [2usize, 3, 8, 16, 200] {
let out = tonemap_to_rgba_with_threads(&colors, scale, threads);
assert_eq!(out, reference, "threads={threads}");
}
let auto = tonemap_to_rgba(&colors, scale);
assert_eq!(auto, reference, "auto thread count");
}
#[test]
fn matches_the_single_threaded_reference_loop() {
let colors = irregular_colors(2_503, 0xabcd_ef01);
let scale = 1.0;
let parallel = tonemap_to_rgba(&colors, scale);
let mut expected = vec![0u8; colors.len() * 4];
for (i, xyz) in colors.iter().enumerate() {
let rgba = xyz_to_srgb_gamma(*xyz * scale);
expected[i * 4] = rgba[0];
expected[i * 4 + 1] = rgba[1];
expected[i * 4 + 2] = rgba[2];
expected[i * 4 + 3] = rgba[3];
}
assert_eq!(parallel, expected);
}
#[test]
fn empty_input_does_not_panic() {
let colors: Vec<Vec3> = Vec::new();
assert_eq!(tonemap_to_rgba(&colors, 1.0), Vec::<u8>::new());
}
#[test]
fn single_pixel_does_not_panic() {
let colors = vec![Vec3::new(0.5, 0.5, 0.5)];
let out = tonemap_to_rgba_with_threads(&colors, 1.0, 8);
assert_eq!(out.len(), 4);
}
#[test]
fn into_variant_matches_the_allocating_form() {
let colors = irregular_colors(3_001, 0x5a5a_1234);
let scale = 0.61;
let expected = tonemap_to_rgba_with_threads(&colors, scale, 1);
let mut reused = vec![0xAB_u8; colors.len() * 4];
for threads in [1usize, 2, 7, 64] {
reused.fill(0xAB);
tonemap_to_rgba_with_threads_into(&colors, scale, threads, &mut reused);
assert_eq!(reused, expected, "threads={threads}");
}
reused.fill(0xAB);
tonemap_to_rgba_into(&colors, scale, &mut reused);
assert_eq!(reused, expected, "auto thread count");
}
#[test]
fn into_variant_converts_only_what_fits() {
let colors = irregular_colors(10, 0x0102_0304);
let full = tonemap_to_rgba(&colors, 1.0);
let mut short = vec![0xCD_u8; 4 * 6 + 3];
tonemap_to_rgba_into(&colors, 1.0, &mut short);
assert_eq!(short[..24], full[..24]);
assert_eq!(short[24..], [0xCD; 3]);
let mut long = vec![0xCD_u8; 4 * 12];
tonemap_to_rgba_into(&colors, 1.0, &mut long);
assert_eq!(long[..40], full[..]);
assert_eq!(long[40..], [0xCD; 8]);
}
fn pre_move_export_tonemap(
samples_per_pixel: u32,
accum: &[Vec3],
color_space: ColorSpace,
) -> Vec<u8> {
let inv_samples = 1.0 / samples_per_pixel as f32;
let mut out = vec![0u8; accum.len() * 4];
for (i, xyz) in accum.iter().enumerate() {
let rgba = if color_space == ColorSpace::Srgb {
xyz_to_srgb_gamma(*xyz * inv_samples)
} else {
color_space.encode(*xyz * inv_samples, ToneMap::AcesFilmic { exposure: 1.0 })
};
out[i * 4..i * 4 + 4].copy_from_slice(&rgba);
}
out
}
fn fnv1a64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, &b| {
(hash ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)
})
}
const EVERY_COLOR_SPACE: [ColorSpace; 4] = [
ColorSpace::Srgb,
ColorSpace::DisplayP3,
ColorSpace::Rec2020,
ColorSpace::AcesCg,
];
#[test]
fn tonemap_accumulation_matches_the_pre_move_export_code() {
let (width, height) = (97_u32, 61_u32);
let accum = irregular_colors((width * height) as usize, 0x0bad_cafe);
for color_space in EVERY_COLOR_SPACE {
assert_eq!(
tonemap_accumulation(width, height, 37, &accum, color_space),
pre_move_export_tonemap(37, &accum, color_space),
"{color_space:?}"
);
for threads in [1_usize, 3, 16] {
assert_eq!(
tonemap_wide_gamut_with_threads(&accum, 1.0 / 37.0, color_space, threads),
tonemap_wide_gamut_with_threads(&accum, 1.0 / 37.0, color_space, 1),
"{color_space:?} threads={threads}"
);
}
}
}
#[test]
fn tonemap_accumulation_output_is_pinned() {
const PINNED: [(ColorSpace, u64); 4] = [
(ColorSpace::Srgb, 0x74aa_b8aa_51e1_87e5),
(ColorSpace::DisplayP3, 0xdcf9_71d4_dbd5_c9d9),
(ColorSpace::Rec2020, 0x4855_a258_e239_8cf4),
(ColorSpace::AcesCg, 0x1f4f_8288_d988_9085),
];
let (width, height) = (8_u32, 5_u32);
let accum = irregular_colors((width * height) as usize, 0x1357_9bdf);
let actual = PINNED.map(|(color_space, _)| {
let before = fnv1a64(&pre_move_export_tonemap(16, &accum, color_space));
let after = fnv1a64(&tonemap_accumulation(
width,
height,
16,
&accum,
color_space,
));
assert_eq!(before, after, "{color_space:?}");
(color_space, after)
});
assert_eq!(actual, PINNED, "actual hashes: {actual:#x?}");
}
}