Skip to main content

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}