ponko/three/post.rs
1//! Post-processing of the 3D frame: exposure, bloom, color grading, tone
2//! mapping and vignette.
3
4use crate::Texture;
5
6/// How HDR light (which can be far brighter than white) is mapped to the
7/// screen.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9#[repr(u32)]
10pub enum Tonemap {
11 /// Film-like: rich contrast, bright colors drift towards white.
12 #[default]
13 Aces = 0,
14 /// Like a camera: keeps the hue of bright colors, softer contrast.
15 AgX = 1,
16 /// Khronos PBR Neutral: colors stay close to the material's own, only
17 /// highlights roll off.
18 Neutral = 2,
19 /// No curve: everything above white clips.
20 None = 3,
21}
22
23/// Antialiasing of the 3D frame. FXAA and SMAA work on the tone mapped image
24/// before sprites/UI; [`Taa`](Self::Taa) works on the lit HDR frame.
25/// ```
26/// use ponko::AntiAliasing;
27/// assert_eq!(AntiAliasing::default(), AntiAliasing::None);
28/// ```
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30#[repr(u32)]
31pub enum AntiAliasing {
32 /// Preserve the image (MSAA still operates on geometry).
33 #[default]
34 None = 0,
35 /// Fast approximate antialiasing, one full-screen pass.
36 Fxaa = 1,
37 /// SMAA 1x, three full-screen passes.
38 Smaa = 2,
39 /// Temporal antialiasing: the camera jitters by a sub-pixel amount each
40 /// frame, and the lit HDR frame (before bloom and tone mapping) is
41 /// blended with last frame's, reprojected along per-pixel motion and
42 /// clamped to the colors nearby, so edges and shading converge on
43 /// smooth ones while still. Costs a motion pass, a resolve pass and
44 /// three full-size HDR images.
45 ///
46 /// Moving models are tracked per entity; skinned models move with their
47 /// model matrix only (limbs bending between frames can ghost slightly),
48 /// and particles, transparent surfaces and [`LodGroup`](crate::LodGroup)s
49 /// have no motion of their own (they reproject with the camera and what's
50 /// behind them, and may smear a little when they move).
51 /// Needs compute-capable devices: WebGL2 draws with FXAA instead. See
52 /// [`Post::taa_sharpness`] and `Ctx::reset_temporal_history`.
53 Taa = 3,
54}
55
56/// Smallest and largest [`Post::render_scale`].
57/// ```
58/// assert_eq!(ponko::MIN_RENDER_SCALE, 0.25);
59/// assert_eq!(ponko::MAX_RENDER_SCALE, 2.0);
60/// ```
61pub const MIN_RENDER_SCALE: f32 = 0.25;
62/// See [`MIN_RENDER_SCALE`].
63pub const MAX_RENDER_SCALE: f32 = 2.0;
64
65/// Ground-truth ambient occlusion. Radius is in world units; quality controls
66/// angular slices and horizon steps. Disabled by default to preserve scenes.
67/// ```
68/// use ponko::AmbientOcclusion;
69/// let ao = AmbientOcclusion { enabled: true, radius: 1.0, ..Default::default() };
70/// assert_eq!(ao.slices, 3);
71/// ```
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct AmbientOcclusion {
74 /// Enable screen-space occlusion of ambient light only. Ignored on
75 /// WebGL2/downlevel devices without compute capabilities.
76 pub enabled: bool,
77 /// Maximum occluder distance in world units, default 1.
78 pub radius: f32,
79 /// Occlusion strength, default 1. Zero preserves ambient light.
80 pub intensity: f32,
81 /// Angular slices, clamped to 1..=8; default 3.
82 pub slices: u32,
83 /// Horizon samples in each direction, clamped to 1..=16; default 6.
84 pub steps: u32,
85}
86impl Default for AmbientOcclusion {
87 fn default() -> Self {
88 Self {
89 enabled: false,
90 radius: 1.0,
91 intensity: 1.0,
92 slices: 3,
93 steps: 6,
94 }
95 }
96}
97
98/// What happens to the lit 3D frame before it reaches the screen. Sprites
99/// and UI drawn on top are not changed.
100///
101/// ```no_run
102/// # use ponko::*;
103/// # fn f(ctx: &mut Ctx) {
104/// ctx.post.exposure = 0.5; // half a stop brighter
105/// ctx.post.bloom = 0.15;
106/// ctx.post.tonemap = Tonemap::Neutral;
107/// ctx.post.temperature = 0.2; // warmer
108/// # }
109/// ```
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct Post {
112 /// The 3D scene is drawn at this fraction of the window's size in each
113 /// direction, then scaled to the window before sprites and UI, which stay
114 /// at the window's resolution: below 1 is cheaper and softer, above 1
115 /// (supersampling) sharper and costlier. From [`MIN_RENDER_SCALE`] to
116 /// [`MAX_RENDER_SCALE`]; anything else is clamped. Default 1 (no extra
117 /// pass).
118 pub render_scale: f32,
119 /// How much the scaled image is sharpened, 0 to 1 (an adaptive
120 /// contrast filter that only touches soft edges). Only used with a
121 /// `render_scale` other than 1. Default 0.
122 pub sharpen: f32,
123 /// Ambient-only GTAO, before opaque lighting.
124 pub ambient_occlusion: AmbientOcclusion,
125 /// Trace GTAO at half width and height, then bilaterally upsample.
126 /// Contact shadows remain full resolution. Default false.
127 pub ao_half_resolution: bool,
128 /// Accumulate GTAO over frames with rotating slices, following the
129 /// camera and moving models (the same motion vectors as TAA) and
130 /// rejecting history whose depth disagrees. Independent of TAA. Default
131 /// false; enable to trade history cost for fewer samples.
132 pub ao_temporal: bool,
133 /// Contact shadows: how far, in world units, each pixel looks toward
134 /// the sun across the depth buffer for something close that blocks
135 /// it. They catch the small shadows the shadow map is too coarse for:
136 /// under feet, pebbles and cups, in creases. 0 (the default) is off;
137 /// 0.3 suits people and props. Only things on screen cast them. Shares
138 /// a full-screen pass with [`AmbientOcclusion`] (one pass for both).
139 pub contact_shadows: f32,
140 /// Screen-space reflections: how strongly glossy surfaces (roughness
141 /// below about 0.7, fully below 0.4) reflect what's on screen, 0 to 1,
142 /// on top of the sky and reflection probes they reflect anyway. Where
143 /// the reflected ray finds nothing on screen (it leaves the screen, goes
144 /// behind something, or runs past
145 /// [`ssr_max_distance`](Self::ssr_max_distance)), the environment's
146 /// reflection stays. Wet streets, polished floors, still water. Things
147 /// off screen or hidden aren't reflected, and normal maps don't bend the
148 /// reflection. The colors come from the last frame's image, so the
149 /// first frame after a camera cut reflects only the environment. A
150 /// half-resolution pass after the depth prepass; [`AntiAliasing::Taa`]
151 /// smooths its grain. Reflections use depth-aware filtered mips as roughness
152 /// increases; the blur footprint is bounded in screen pixels. 0 (the default) is off.
153 /// ```
154 /// use ponko::*;
155 /// assert_eq!(Post::default().ssr, 0.0);
156 /// let wet_street = Post { ssr: 1.0, ..Default::default() };
157 /// assert_eq!(wet_street.ssr_max_distance, 30.0);
158 /// ```
159 pub ssr: f32,
160 /// How far, in world units, a screen-space reflection looks along the
161 /// reflected ray; it fades out toward the end. Default 30.
162 pub ssr_max_distance: f32,
163 /// Post antialiasing. Default off to preserve existing scenes.
164 pub anti_aliasing: AntiAliasing,
165 /// How much [`AntiAliasing::Taa`] sharpens the result (it softens the
166 /// image a little), 0 to 1. Default 0.25.
167 pub taa_sharpness: f32,
168 /// Brightness in stops: +1 doubles the light, -1 halves it. Default 0.
169 pub exposure: f32,
170 /// How light maps to the screen. Default [`Tonemap::Aces`].
171 pub tonemap: Tonemap,
172 /// How strongly bright light glows into its surroundings; 0 turns bloom
173 /// off. Default 0.05.
174 pub bloom: f32,
175 /// How bright light must be to glow (1 is white). Default 1.
176 pub bloom_threshold: f32,
177 /// 1 keeps the contrast, higher is punchier, lower flatter. Default 1.
178 pub contrast: f32,
179 /// 1 keeps the colors, 0 is grey, higher is more vivid. Default 1.
180 pub saturation: f32,
181 /// White balance, -1 (cool, blue) to 1 (warm, orange). Default 0.
182 pub temperature: f32,
183 /// White balance, -1 (green) to 1 (magenta). Default 0.
184 pub tint: f32,
185 /// How much the corners darken, 0 to 1. Default 0.
186 pub vignette: f32,
187 /// Color fringes toward the edges, as a cheap lens splits red, green
188 /// and blue apart, 0 to 1. Default 0.
189 /// ```
190 /// use ponko::*;
191 /// let speed = Post { chromatic_aberration: 0.8, lens_distortion: -0.26, lens_scale: 1.04, ..Default::default() };
192 /// assert_eq!(Post::default().chromatic_aberration, 0.0);
193 /// # let _ = speed;
194 /// ```
195 pub chromatic_aberration: f32,
196 /// Bends the picture as a lens would, -1 to 1: above 0 bulges the
197 /// middle out (barrel), below 0 pinches it in (pincushion). Default 0.
198 pub lens_distortion: f32,
199 /// Zooms the picture in (above 1) or out after the distortion, to hide
200 /// the edges it pulls in. Above zero; default 1.
201 pub lens_scale: f32,
202 /// A color lookup table for grading, applied after tone mapping: a
203 /// strip of `n` squares of `n` x `n` pixels (for example 1024 x 32),
204 /// red across each square, green down it, blue from square to square.
205 /// A neutral one changes nothing; paint over it in an image editor to
206 /// grade. Default none.
207 pub lut: Option<Texture>,
208}
209
210impl Default for Post {
211 fn default() -> Self {
212 Self {
213 render_scale: 1.0,
214 sharpen: 0.0,
215 ambient_occlusion: AmbientOcclusion::default(),
216 ao_half_resolution: false,
217 ao_temporal: false,
218 contact_shadows: 0.0,
219 ssr: 0.0,
220 ssr_max_distance: 30.0,
221 anti_aliasing: AntiAliasing::None,
222 taa_sharpness: 0.25,
223 exposure: 0.0,
224 tonemap: Tonemap::Aces,
225 bloom: 0.05,
226 bloom_threshold: 1.0,
227 contrast: 1.0,
228 saturation: 1.0,
229 temperature: 0.0,
230 tint: 0.0,
231 vignette: 0.0,
232 chromatic_aberration: 0.0,
233 lens_distortion: 0.0,
234 lens_scale: 1.0,
235 lut: None,
236 }
237 }
238}
239
240impl Post {
241 /// The size the 3D scene is drawn at in a window of `window` pixels:
242 /// scaled by [`render_scale`](Self::render_scale) (clamped; a NaN counts
243 /// as 1), at least 1 pixel and at most `limit` wide (0: no limit).
244 ///
245 /// ```
246 /// use ponko::*;
247 /// let post = Post { render_scale: 0.5, ..Default::default() };
248 /// assert_eq!(post.render_size((1920, 1080)), (960, 540));
249 /// ```
250 #[must_use]
251 pub fn render_size(&self, window: (u32, u32)) -> (u32, u32) {
252 scaled_size(window, self.render_scale, 0)
253 }
254
255 /// A neutral lookup table of `n` x `n` x `n` colors as RGBA pixels,
256 /// `n * n` wide and `n` high: the starting point for a [`Post::lut`].
257 ///
258 /// ```no_run
259 /// # use ponko::*;
260 /// # fn f(ctx: &mut Ctx) {
261 /// let (w, h, pixels) = Post::neutral_lut(32);
262 /// ctx.post.lut = Some(ctx.create_texture(w, h, &pixels));
263 /// # }
264 /// ```
265 ///
266 /// # Panics
267 ///
268 /// If `n` is not between 2 and 64.
269 pub fn neutral_lut(n: u32) -> (u32, u32, Vec<u8>) {
270 assert!(
271 (2..=64).contains(&n),
272 "a lookup table has 2 to 64 steps per color, not {n}"
273 );
274 let step = |i: u32| (i * 255 + (n - 1) / 2) / (n - 1);
275 let mut pixels = Vec::with_capacity((n * n * n * 4) as usize);
276 for g in 0..n {
277 for b in 0..n {
278 for r in 0..n {
279 pixels.extend_from_slice(&[step(r) as u8, step(g) as u8, step(b) as u8, 255]);
280 }
281 }
282 }
283 (n * n, n, pixels)
284 }
285}
286
287/// `window` scaled by `scale` (clamped to the allowed range), at least 1 and
288/// at most `limit` pixels per side (0: no limit).
289pub(crate) fn scaled_size(window: (u32, u32), scale: f32, limit: u32) -> (u32, u32) {
290 let scale = if scale.is_nan() {
291 1.0
292 } else {
293 scale.clamp(MIN_RENDER_SCALE, MAX_RENDER_SCALE)
294 };
295 let limit = if limit == 0 { u32::MAX } else { limit };
296 let side = |v: u32| ((v as f32 * scale).round() as u32).clamp(1, limit.max(1));
297 (side(window.0), side(window.1))
298}
299
300/// [`Post`] as the post shader reads it.
301#[repr(C)]
302#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
303pub(crate) struct PostUniform {
304 /// x: exposure multiplier, y: bloom, z: contrast, w: saturation.
305 pub grade: [f32; 4],
306 /// White balance multipliers in LMS space; w: vignette.
307 pub balance: [f32; 4],
308 /// x: tone map, y: lookup table size (0 for none), z: bloom on (1/0).
309 pub mode: [u32; 4],
310 /// x: bloom threshold, y: soft knee.
311 pub bloom: [f32; 4],
312 /// For the scaling pass: one output pixel in uv (x, y), the sharpness
313 /// (z) and the scene's size over the output's (w).
314 pub output: [f32; 4],
315 /// Lens distortion: x theta (or its inverse when pinching), y sigma,
316 /// z one over the scale, w 1 barrel, -1 pincushion, 0 off. See [`lens`].
317 pub lens: [f32; 4],
318 /// x: chromatic aberration amount (0 off).
319 pub fringe: [f32; 4],
320}
321
322/// The lens distortion terms, as Unity's and other engines' "lens
323/// distortion" effects compute them: an angle `theta` that grows with the
324/// strength (capped at 160 degrees), and `sigma = 2 tan(theta / 2)`.
325fn lens(post: &Post) -> [f32; 4] {
326 let intensity = post.lens_distortion;
327 if !intensity.is_finite() || intensity == 0.0 {
328 return [0.0, 0.0, 1.0, 0.0];
329 }
330 let intensity = intensity.clamp(-1.0, 1.0);
331 let amount = 1.6 * (intensity * 100.0).abs().max(1.0);
332 let theta = amount.min(160.0).to_radians();
333 let sigma = 2.0 * (theta * 0.5).tan();
334 let scale = if post.lens_scale.is_finite() && post.lens_scale > 0.0 {
335 post.lens_scale
336 } else {
337 1.0
338 };
339 [
340 if intensity > 0.0 { theta } else { 1.0 / theta },
341 sigma,
342 1.0 / scale,
343 if intensity > 0.0 { 1.0 } else { -1.0 },
344 ]
345}
346
347impl PostUniform {
348 pub fn new(post: &Post, lut_size: Option<u32>) -> Self {
349 let [l, m, s] = white_balance(post.temperature, post.tint);
350 let bloom = post.bloom.max(0.0);
351 Self {
352 grade: [
353 post.exposure.exp2(),
354 bloom,
355 post.contrast.max(0.0),
356 post.saturation.max(0.0),
357 ],
358 balance: [l, m, s, post.vignette.clamp(0.0, 1.0)],
359 mode: [post.tonemap as u32, lut_size.unwrap_or(0), u32::from(bloom > 0.0), 0],
360 bloom: [post.bloom_threshold.max(0.0), 0.5, 0.0, 0.0],
361 output: [0.0; 4],
362 lens: lens(post),
363 fringe: [
364 if post.chromatic_aberration.is_finite() {
365 post.chromatic_aberration.clamp(0.0, 1.0) * 0.05
366 } else {
367 0.0
368 },
369 0.0,
370 0.0,
371 0.0,
372 ],
373 }
374 }
375
376 /// The same, for a scene drawn at `scene` pixels and scaled to
377 /// `output` ones.
378 pub fn scaled(mut self, post: &Post, scene: (u32, u32), output: (u32, u32)) -> Self {
379 let sharpen = if post.sharpen.is_nan() {
380 0.0
381 } else {
382 post.sharpen.clamp(0.0, 1.0)
383 };
384 self.output = [
385 1.0 / output.0.max(1) as f32,
386 1.0 / output.1.max(1) as f32,
387 sharpen,
388 scene.0 as f32 / output.0.max(1) as f32,
389 ];
390 self
391 }
392}
393
394/// Multipliers in LMS (cone response) space that move the white point, as
395/// Unity and others do it: shift along the daylight curve by `temperature`,
396/// across it by `tint`.
397fn white_balance(temperature: f32, tint: f32) -> [f32; 3] {
398 let t1 = temperature.clamp(-1.0, 1.0) * 100.0 / 65.0;
399 let t2 = tint.clamp(-1.0, 1.0) * 100.0 / 65.0;
400 let x = 0.31271 - t1 * if t1 < 0.0 { 0.1 } else { 0.05 };
401 let y = 2.87 * x - 3.0 * x * x - 0.275_095_07 + t2 * 0.05;
402 let big_x = x / y;
403 let big_z = (1.0 - x - y) / y;
404 let lms = [
405 0.7328 * big_x + 0.4296 - 0.1624 * big_z,
406 -0.7036 * big_x + 1.6975 + 0.0061 * big_z,
407 0.0030 * big_x + 0.0136 + 0.9834 * big_z,
408 ];
409 let d65 = [0.949_237, 1.035_42, 1.087_28];
410 [d65[0] / lms[0], d65[1] / lms[1], d65[2] / lms[2]]
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn scaled_sizes_round_clamp_and_never_reach_zero() {
419 assert_eq!(scaled_size((1920, 1080), 1.0, 0), (1920, 1080));
420 assert_eq!(scaled_size((1920, 1080), 0.5, 0), (960, 540));
421 assert_eq!(scaled_size((1921, 1081), 0.5, 0), (961, 541));
422 assert_eq!(scaled_size((100, 100), 0.01, 0), (25, 25), "clamped to the minimum");
423 assert_eq!(scaled_size((100, 100), 9.0, 0), (200, 200), "clamped to the maximum");
424 assert_eq!(scaled_size((100, 100), f32::NAN, 0), (100, 100));
425 assert_eq!(scaled_size((100, 100), f32::INFINITY, 0), (200, 200));
426 assert_eq!(scaled_size((1, 1), 0.25, 0), (1, 1));
427 assert_eq!(scaled_size((0, 0), 1.0, 0), (1, 1));
428 assert_eq!(
429 scaled_size((4096, 4096), 2.0, 4096),
430 (4096, 4096),
431 "within the GPU's limit"
432 );
433 }
434
435 #[test]
436 fn the_scaling_uniform_holds_the_pixel_size_and_ratio() {
437 let post = Post {
438 sharpen: 2.0,
439 ..Default::default()
440 };
441 let u = PostUniform::new(&post, None).scaled(&post, (960, 540), (1920, 1080));
442 assert_eq!(u.output, [1.0 / 1920.0, 1.0 / 1080.0, 1.0, 0.5], "sharpness is clamped");
443 }
444
445 #[test]
446 fn lens_terms_follow_the_strength_and_stay_off_at_zero() {
447 let off = PostUniform::new(&Post::default(), None);
448 assert_eq!((off.lens[3], off.fringe[0]), (0.0, 0.0));
449 let bulge = PostUniform::new(
450 &Post {
451 lens_distortion: 0.5,
452 chromatic_aberration: 2.0,
453 ..Default::default()
454 },
455 None,
456 );
457 let theta = 80f32.to_radians();
458 assert!((bulge.lens[0] - theta).abs() < 1e-6);
459 assert!((bulge.lens[1] - 2.0 * (theta / 2.0).tan()).abs() < 1e-5);
460 assert_eq!((bulge.lens[2], bulge.lens[3]), (1.0, 1.0));
461 assert_eq!(bulge.fringe[0], 0.05, "clamped to 1");
462 let pinch = PostUniform::new(
463 &Post {
464 lens_distortion: -0.26,
465 lens_scale: 1.04,
466 ..Default::default()
467 },
468 None,
469 );
470 let theta = (1.6f32 * 26.0).to_radians();
471 assert!((pinch.lens[0] - 1.0 / theta).abs() < 1e-5);
472 assert!((pinch.lens[2] - 1.0 / 1.04).abs() < 1e-6);
473 assert_eq!(pinch.lens[3], -1.0);
474 let strong = lens(&Post {
475 lens_distortion: 1.0,
476 ..Default::default()
477 });
478 assert!((strong[0] - 160f32.to_radians()).abs() < 1e-6, "capped at 160 degrees");
479 assert_eq!(size_of::<PostUniform>() % 16, 0);
480 }
481
482 #[test]
483 fn neutral_white_balance_changes_nothing() {
484 for v in white_balance(0.0, 0.0) {
485 assert!((v - 1.0).abs() < 0.01, "{v}");
486 }
487 }
488
489 #[test]
490 fn warm_white_balance_boosts_long_wavelengths() {
491 let [l, _, s] = white_balance(0.5, 0.0);
492 assert!(l > s, "warm: {l} vs {s}");
493 let [l, _, s] = white_balance(-0.5, 0.0);
494 assert!(l < s, "cool: {l} vs {s}");
495 }
496
497 #[test]
498 fn neutral_lut_corners_are_black_and_white() {
499 let (w, h, px) = Post::neutral_lut(4);
500 assert_eq!((w, h, px.len()), (16, 4, 16 * 4 * 4));
501 assert_eq!(&px[..4], &[0, 0, 0, 255]);
502 // Last square (blue full), last row (green full), last column (red full).
503 let last = ((h - 1) * w + w - 1) as usize * 4;
504 assert_eq!(&px[last..last + 4], &[255, 255, 255, 255]);
505 }
506}