use crate::Texture;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u32)]
pub enum Tonemap {
#[default]
Aces = 0,
AgX = 1,
Neutral = 2,
None = 3,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u32)]
pub enum AntiAliasing {
#[default]
None = 0,
Fxaa = 1,
Smaa = 2,
Taa = 3,
}
pub const MIN_RENDER_SCALE: f32 = 0.25;
pub const MAX_RENDER_SCALE: f32 = 2.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AmbientOcclusion {
pub enabled: bool,
pub radius: f32,
pub intensity: f32,
pub slices: u32,
pub steps: u32,
}
impl Default for AmbientOcclusion {
fn default() -> Self {
Self {
enabled: false,
radius: 1.0,
intensity: 1.0,
slices: 3,
steps: 6,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Post {
pub render_scale: f32,
pub sharpen: f32,
pub ambient_occlusion: AmbientOcclusion,
pub ao_half_resolution: bool,
pub ao_temporal: bool,
pub contact_shadows: f32,
pub ssr: f32,
pub ssr_max_distance: f32,
pub anti_aliasing: AntiAliasing,
pub taa_sharpness: f32,
pub exposure: f32,
pub tonemap: Tonemap,
pub bloom: f32,
pub bloom_threshold: f32,
pub contrast: f32,
pub saturation: f32,
pub temperature: f32,
pub tint: f32,
pub vignette: f32,
pub chromatic_aberration: f32,
pub lens_distortion: f32,
pub lens_scale: f32,
pub lut: Option<Texture>,
}
impl Default for Post {
fn default() -> Self {
Self {
render_scale: 1.0,
sharpen: 0.0,
ambient_occlusion: AmbientOcclusion::default(),
ao_half_resolution: false,
ao_temporal: false,
contact_shadows: 0.0,
ssr: 0.0,
ssr_max_distance: 30.0,
anti_aliasing: AntiAliasing::None,
taa_sharpness: 0.25,
exposure: 0.0,
tonemap: Tonemap::Aces,
bloom: 0.05,
bloom_threshold: 1.0,
contrast: 1.0,
saturation: 1.0,
temperature: 0.0,
tint: 0.0,
vignette: 0.0,
chromatic_aberration: 0.0,
lens_distortion: 0.0,
lens_scale: 1.0,
lut: None,
}
}
}
impl Post {
#[must_use]
pub fn render_size(&self, window: (u32, u32)) -> (u32, u32) {
scaled_size(window, self.render_scale, 0)
}
pub fn neutral_lut(n: u32) -> (u32, u32, Vec<u8>) {
assert!(
(2..=64).contains(&n),
"a lookup table has 2 to 64 steps per color, not {n}"
);
let step = |i: u32| (i * 255 + (n - 1) / 2) / (n - 1);
let mut pixels = Vec::with_capacity((n * n * n * 4) as usize);
for g in 0..n {
for b in 0..n {
for r in 0..n {
pixels.extend_from_slice(&[step(r) as u8, step(g) as u8, step(b) as u8, 255]);
}
}
}
(n * n, n, pixels)
}
}
pub(crate) fn scaled_size(window: (u32, u32), scale: f32, limit: u32) -> (u32, u32) {
let scale = if scale.is_nan() {
1.0
} else {
scale.clamp(MIN_RENDER_SCALE, MAX_RENDER_SCALE)
};
let limit = if limit == 0 { u32::MAX } else { limit };
let side = |v: u32| ((v as f32 * scale).round() as u32).clamp(1, limit.max(1));
(side(window.0), side(window.1))
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub(crate) struct PostUniform {
pub grade: [f32; 4],
pub balance: [f32; 4],
pub mode: [u32; 4],
pub bloom: [f32; 4],
pub output: [f32; 4],
pub lens: [f32; 4],
pub fringe: [f32; 4],
}
fn lens(post: &Post) -> [f32; 4] {
let intensity = post.lens_distortion;
if !intensity.is_finite() || intensity == 0.0 {
return [0.0, 0.0, 1.0, 0.0];
}
let intensity = intensity.clamp(-1.0, 1.0);
let amount = 1.6 * (intensity * 100.0).abs().max(1.0);
let theta = amount.min(160.0).to_radians();
let sigma = 2.0 * (theta * 0.5).tan();
let scale = if post.lens_scale.is_finite() && post.lens_scale > 0.0 {
post.lens_scale
} else {
1.0
};
[
if intensity > 0.0 { theta } else { 1.0 / theta },
sigma,
1.0 / scale,
if intensity > 0.0 { 1.0 } else { -1.0 },
]
}
impl PostUniform {
pub fn new(post: &Post, lut_size: Option<u32>) -> Self {
let [l, m, s] = white_balance(post.temperature, post.tint);
let bloom = post.bloom.max(0.0);
Self {
grade: [
post.exposure.exp2(),
bloom,
post.contrast.max(0.0),
post.saturation.max(0.0),
],
balance: [l, m, s, post.vignette.clamp(0.0, 1.0)],
mode: [post.tonemap as u32, lut_size.unwrap_or(0), u32::from(bloom > 0.0), 0],
bloom: [post.bloom_threshold.max(0.0), 0.5, 0.0, 0.0],
output: [0.0; 4],
lens: lens(post),
fringe: [
if post.chromatic_aberration.is_finite() {
post.chromatic_aberration.clamp(0.0, 1.0) * 0.05
} else {
0.0
},
0.0,
0.0,
0.0,
],
}
}
pub fn scaled(mut self, post: &Post, scene: (u32, u32), output: (u32, u32)) -> Self {
let sharpen = if post.sharpen.is_nan() {
0.0
} else {
post.sharpen.clamp(0.0, 1.0)
};
self.output = [
1.0 / output.0.max(1) as f32,
1.0 / output.1.max(1) as f32,
sharpen,
scene.0 as f32 / output.0.max(1) as f32,
];
self
}
}
fn white_balance(temperature: f32, tint: f32) -> [f32; 3] {
let t1 = temperature.clamp(-1.0, 1.0) * 100.0 / 65.0;
let t2 = tint.clamp(-1.0, 1.0) * 100.0 / 65.0;
let x = 0.31271 - t1 * if t1 < 0.0 { 0.1 } else { 0.05 };
let y = 2.87 * x - 3.0 * x * x - 0.275_095_07 + t2 * 0.05;
let big_x = x / y;
let big_z = (1.0 - x - y) / y;
let lms = [
0.7328 * big_x + 0.4296 - 0.1624 * big_z,
-0.7036 * big_x + 1.6975 + 0.0061 * big_z,
0.0030 * big_x + 0.0136 + 0.9834 * big_z,
];
let d65 = [0.949_237, 1.035_42, 1.087_28];
[d65[0] / lms[0], d65[1] / lms[1], d65[2] / lms[2]]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaled_sizes_round_clamp_and_never_reach_zero() {
assert_eq!(scaled_size((1920, 1080), 1.0, 0), (1920, 1080));
assert_eq!(scaled_size((1920, 1080), 0.5, 0), (960, 540));
assert_eq!(scaled_size((1921, 1081), 0.5, 0), (961, 541));
assert_eq!(scaled_size((100, 100), 0.01, 0), (25, 25), "clamped to the minimum");
assert_eq!(scaled_size((100, 100), 9.0, 0), (200, 200), "clamped to the maximum");
assert_eq!(scaled_size((100, 100), f32::NAN, 0), (100, 100));
assert_eq!(scaled_size((100, 100), f32::INFINITY, 0), (200, 200));
assert_eq!(scaled_size((1, 1), 0.25, 0), (1, 1));
assert_eq!(scaled_size((0, 0), 1.0, 0), (1, 1));
assert_eq!(
scaled_size((4096, 4096), 2.0, 4096),
(4096, 4096),
"within the GPU's limit"
);
}
#[test]
fn the_scaling_uniform_holds_the_pixel_size_and_ratio() {
let post = Post {
sharpen: 2.0,
..Default::default()
};
let u = PostUniform::new(&post, None).scaled(&post, (960, 540), (1920, 1080));
assert_eq!(u.output, [1.0 / 1920.0, 1.0 / 1080.0, 1.0, 0.5], "sharpness is clamped");
}
#[test]
fn lens_terms_follow_the_strength_and_stay_off_at_zero() {
let off = PostUniform::new(&Post::default(), None);
assert_eq!((off.lens[3], off.fringe[0]), (0.0, 0.0));
let bulge = PostUniform::new(
&Post {
lens_distortion: 0.5,
chromatic_aberration: 2.0,
..Default::default()
},
None,
);
let theta = 80f32.to_radians();
assert!((bulge.lens[0] - theta).abs() < 1e-6);
assert!((bulge.lens[1] - 2.0 * (theta / 2.0).tan()).abs() < 1e-5);
assert_eq!((bulge.lens[2], bulge.lens[3]), (1.0, 1.0));
assert_eq!(bulge.fringe[0], 0.05, "clamped to 1");
let pinch = PostUniform::new(
&Post {
lens_distortion: -0.26,
lens_scale: 1.04,
..Default::default()
},
None,
);
let theta = (1.6f32 * 26.0).to_radians();
assert!((pinch.lens[0] - 1.0 / theta).abs() < 1e-5);
assert!((pinch.lens[2] - 1.0 / 1.04).abs() < 1e-6);
assert_eq!(pinch.lens[3], -1.0);
let strong = lens(&Post {
lens_distortion: 1.0,
..Default::default()
});
assert!((strong[0] - 160f32.to_radians()).abs() < 1e-6, "capped at 160 degrees");
assert_eq!(size_of::<PostUniform>() % 16, 0);
}
#[test]
fn neutral_white_balance_changes_nothing() {
for v in white_balance(0.0, 0.0) {
assert!((v - 1.0).abs() < 0.01, "{v}");
}
}
#[test]
fn warm_white_balance_boosts_long_wavelengths() {
let [l, _, s] = white_balance(0.5, 0.0);
assert!(l > s, "warm: {l} vs {s}");
let [l, _, s] = white_balance(-0.5, 0.0);
assert!(l < s, "cool: {l} vs {s}");
}
#[test]
fn neutral_lut_corners_are_black_and_white() {
let (w, h, px) = Post::neutral_lut(4);
assert_eq!((w, h, px.len()), (16, 4, 16 * 4 * 4));
assert_eq!(&px[..4], &[0, 0, 0, 255]);
let last = ((h - 1) * w + w - 1) as usize * 4;
assert_eq!(&px[last..last + 4], &[255, 255, 255, 255]);
}
}