Skip to main content

fidget_raster/
effects.rs

1//! Post-processing effects for rendered images
2
3use super::{
4    ColorImage, DistancePixel, GeometryBuffer, GeometryPixel, Image, ImageSize,
5    ThreadPool,
6};
7use nalgebra::{
8    Const, Matrix3, MatrixXx2, MatrixXx3, OMatrix, RowVector2, RowVector3,
9    Vector3, Vector4,
10};
11use rand::prelude::*;
12
13/// Denoise normals by replacing back-facing normals with their average neighbor
14///
15/// # Panics
16/// If the images have different widths or heights
17pub fn denoise_normals(
18    image: &GeometryBuffer,
19    threads: Option<&ThreadPool>,
20) -> GeometryBuffer {
21    let radius = 2;
22    let mut out = GeometryBuffer::new(image.size());
23    out.apply_effect(
24        |x, y| {
25            let depth = image[(y, x)].depth;
26            let normal = if depth > 0.0 {
27                denoise_pixel(image, x, y, radius)
28            } else {
29                [0.0; 3]
30            };
31            GeometryPixel { depth, normal }
32        },
33        threads,
34    );
35    out
36}
37
38/// Combines two images with shading
39///
40/// # Panics
41/// If the images have different widths or heights
42pub fn apply_shading(
43    image: &GeometryBuffer,
44    ssao: bool,
45    threads: Option<&ThreadPool>,
46) -> ColorImage {
47    let ssao = if ssao {
48        let ssao = compute_ssao(image, threads);
49        Some(blur_ssao(&ssao, threads))
50    } else {
51        None
52    };
53
54    let size = image.size();
55    let mut out = ColorImage::new(ImageSize::new(size.width(), size.height()));
56    out.apply_effect(
57        |x, y| {
58            if image[(y, x)].depth > 0.0 {
59                shade_pixel(image, ssao.as_ref(), x, y)
60            } else {
61                [0u8; 3]
62            }
63        },
64        threads,
65    );
66    out
67}
68
69/// Computes SSAO occlusion at each pixel in an image
70///
71/// # Panics
72/// If the images have different widths or heights
73pub fn compute_ssao(
74    image: &GeometryBuffer,
75    threads: Option<&ThreadPool>,
76) -> Image<f32> {
77    let ssao_kernel = ssao_kernel(64);
78    let ssao_noise = ssao_noise(16 * 16);
79
80    let size = image.size();
81    let mut out =
82        Image::<f32>::new(ImageSize::new(size.width(), size.height()));
83    out.apply_effect(
84        |x, y| {
85            if image[(y, x)].depth > 0.0 {
86                compute_pixel_ssao(image, x, y, &ssao_kernel, &ssao_noise)
87            } else {
88                f32::NAN
89            }
90        },
91        threads,
92    );
93
94    out
95}
96
97/// Blurs the given image, which is expected to be an SSAO occlusion map
98pub fn blur_ssao(
99    ssao: &Image<f32>,
100    threads: Option<&ThreadPool>,
101) -> Image<f32> {
102    let mut out = Image::<f32>::new(ssao.size());
103    let blur_radius = 2;
104    out.apply_effect(
105        |x, y| {
106            if ssao[(y, x)].is_nan() {
107                f32::NAN
108            } else {
109                compute_pixel_blur(ssao, x, y, blur_radius)
110            }
111        },
112        threads,
113    );
114    out
115}
116
117/// Compute shading for a single pixel
118fn shade_pixel(
119    image: &GeometryBuffer,
120    ssao: Option<&Image<f32>>,
121    x: usize,
122    y: usize,
123) -> [u8; 3] {
124    let [nx, ny, nz] = image[(y, x)].normal;
125    let n = Vector3::new(nx, ny, nz).normalize();
126
127    // Convert from pixel to world coordinates
128    let p = Vector3::new(
129        2.0 * (x as f32 / image.width() as f32 - 0.5),
130        2.0 * (y as f32 / image.height() as f32 - 0.5),
131        2.0 * (image[(y, x)].depth / image.depth() as f32 - 0.5),
132    );
133
134    let lights = [
135        Vector4::new(5.0, -5.0, 10.0, 0.5),
136        Vector4::new(-5.0, 0.0, 10.0, 0.15),
137        Vector4::new(0.0, -5.0, 10.0, 0.15),
138    ];
139    let mut accum = 0.2; // ambient
140    for light in lights {
141        let light_dir = (light.xyz() - p).normalize();
142        accum += light_dir.dot(&n).max(0.0) * light.w;
143    }
144    if let Some(ssao) = ssao {
145        accum *= ssao[(y, x)] * 0.6 + 0.4;
146    }
147
148    accum = accum.clamp(0.0, 1.0);
149
150    let c = (accum * 255.0) as u8;
151    [c, c, c]
152}
153
154/// Compute an SSAO shading factor for a single pixel
155///
156/// Returns NAN if the pixel is empty (i.e. its depth is 0)
157fn compute_pixel_ssao(
158    image: &GeometryBuffer,
159    x: usize,
160    y: usize,
161    kernel: &OMatrix<f32, nalgebra::Dyn, Const<3>>,
162    noise: &OMatrix<f32, nalgebra::Dyn, Const<2>>,
163) -> f32 {
164    let pos = (y, x);
165    let GeometryPixel {
166        normal: [nx, ny, nz],
167        depth: d,
168    } = image[pos];
169
170    if d == 0.0 {
171        return f32::NAN;
172    }
173
174    // XXX The implementation in libfive-cuda adds a 0.5 pixel offset
175    let p = Vector3::new(
176        (((x as f32) / image.width() as f32) - 0.5) * 2.0,
177        (((y as f32) / image.height() as f32) - 0.5) * 2.0,
178        ((d / image.depth() as f32) - 0.5) * 2.0,
179    );
180
181    // Get normal from the image
182    let n = Vector3::new(nx, ny, nz).normalize();
183
184    // Get a rotation vector based on pixel index, and add a Z coordinate
185    let idx = pos.0 + pos.1 * image.width();
186    let rvec = noise.row((idx * 19) % noise.nrows()).transpose();
187    let rvec = Vector3::new(rvec.x, rvec.y, 0.0);
188
189    // Build our transform matrix, using the Gram-Schmidt process
190    let tangent = (rvec - n * rvec.dot(&n)).normalize();
191    let bitangent = n.cross(&tangent);
192    let tbn = Matrix3::from_columns(&[tangent, bitangent, n]);
193
194    const RADIUS: f32 = 0.1;
195    let mut occlusion = 0.0;
196    for i in 0..kernel.nrows() {
197        // position in world coordinates
198        let sample_pos = tbn * kernel.row(i).transpose() * RADIUS + p;
199
200        // convert to pixel coordinates
201        // XXX this distorts samples for non-square images
202        let px = ((sample_pos.x / 2.0) + 0.5) * image.width() as f32;
203        let py = ((sample_pos.y / 2.0) + 0.5) * image.height() as f32;
204
205        // Get depth from heightmap
206        let actual_h = if px < image.width() as f32
207            && py < image.height() as f32
208            && px > 0.0
209            && py > 0.0
210        {
211            image[(py as usize, px as usize)].depth
212        } else {
213            0.0
214        };
215
216        let actual_z = ((actual_h / image.depth() as f32) - 0.5) * 2.0;
217
218        let dz = sample_pos.z - actual_z;
219        if dz < RADIUS {
220            occlusion += (sample_pos.z <= actual_z) as usize as f32;
221        } else if dz < RADIUS * 2.0 && sample_pos.z <= actual_z {
222            occlusion += ((RADIUS - (dz - RADIUS)) / RADIUS).powi(2);
223        }
224    }
225    1.0 - (occlusion / kernel.nrows() as f32)
226}
227
228/// If the pixel has a back-facing normal, then pick a normal from neighbors
229fn denoise_pixel(
230    image: &GeometryBuffer,
231    x: usize,
232    y: usize,
233    denoise_radius: isize,
234) -> [f32; 3] {
235    let n = image[(y, x)].normal;
236    if n[2] > 0.0 {
237        return n;
238    }
239    let x = x as isize;
240    let y = y as isize;
241    [
242        (0, 0),
243        (-denoise_radius, 0),
244        (0, -denoise_radius),
245        (-denoise_radius, -denoise_radius),
246    ]
247    .into_iter()
248    .flat_map(|(xmin, ymin)| {
249        let mut sum = Vector3::zeros();
250        let mut count = 0;
251        for i in 0..=denoise_radius {
252            for j in 0..=denoise_radius {
253                let tx = x + xmin + i;
254                let ty = y + ymin + j;
255                if tx >= 0
256                    && ty >= 0
257                    && (tx as usize) < image.width()
258                    && (ty as usize) < image.height()
259                {
260                    let pos = (ty as usize, tx as usize);
261                    if image[pos].depth != 0.0 {
262                        let n = image[pos].normal;
263                        sum += Vector3::new(n[0], n[1], n[2]);
264                        count += 1;
265                    }
266                }
267            }
268        }
269        if count == 0 {
270            return None;
271        }
272        let mean = sum / count as f32;
273        let mut score = 0.0;
274        for i in 0..=denoise_radius {
275            for j in 0..=denoise_radius {
276                let tx = x + xmin + i;
277                let ty = y + ymin + j;
278                if tx >= 0
279                    && ty >= 0
280                    && (tx as usize) < image.width()
281                    && (ty as usize) < image.height()
282                {
283                    let pos = (ty as usize, tx as usize);
284                    if image[pos].depth != 0.0 {
285                        let n = image[pos].normal;
286                        score += Vector3::new(n[0], n[1], n[2]).dot(&mean);
287                    }
288                }
289            }
290        }
291        Some((score, mean.into()))
292    })
293    .max_by_key(|(score, _mean)| ordered_float::OrderedFloat(*score))
294    .unwrap_or((0.0, n))
295    .1
296}
297
298/// Computes a single blurred pixel
299fn compute_pixel_blur(
300    ssao: &Image<f32>,
301    x: usize,
302    y: usize,
303    blur_radius: isize,
304) -> f32 {
305    let x = x as isize;
306    let y = y as isize;
307    [
308        (0, 0),
309        (-blur_radius, 0),
310        (0, -blur_radius),
311        (-blur_radius, -blur_radius),
312    ]
313    .into_iter()
314    .flat_map(|(xmin, ymin)| {
315        let mut sum = 0.0;
316        let mut count = 0;
317        for i in 0..=blur_radius {
318            for j in 0..=blur_radius {
319                let tx = x + xmin + i;
320                let ty = y + ymin + j;
321                if tx >= 0
322                    && ty >= 0
323                    && (tx as usize) < ssao.width()
324                    && (ty as usize) < ssao.height()
325                {
326                    let s = ssao[(ty as usize, tx as usize)];
327                    if !s.is_nan() {
328                        sum += s;
329                        count += 1;
330                    }
331                }
332            }
333        }
334        if count == 0 {
335            return None;
336        }
337        let mean = sum / count as f32;
338        let mut stdev = 0.0;
339        for i in 0..=blur_radius {
340            for j in 0..=blur_radius {
341                let tx = x + xmin + i;
342                let ty = y + ymin + j;
343                if tx >= 0
344                    && ty >= 0
345                    && (tx as usize) < ssao.width()
346                    && (ty as usize) < ssao.height()
347                {
348                    let s = ssao[(ty as usize, tx as usize)];
349                    if !s.is_nan() {
350                        stdev += (mean - s).powi(2);
351                    }
352                }
353            }
354        }
355        Some((stdev / count as f32, mean))
356    })
357    .min_by_key(|(stdev, _mean)| ordered_float::OrderedFloat(*stdev))
358    .unwrap_or_else(|| (0.0, ssao[(y as usize, x as usize)]))
359    .1
360}
361
362/// Returns a SSAO kernel matrix
363///
364/// The resulting matrix is a list of row vectors representing points in a
365/// hemisphere with a maximum radius of 1.0, used for sampling the depth buffer.
366///
367/// It should be reoriented based on the surface normal.
368pub fn ssao_kernel(n: usize) -> OMatrix<f32, nalgebra::Dyn, Const<3>> {
369    // Based on http://john-chapman-graphics.blogspot.com/2013/01/ssao-tutorial.html
370    use rand::prelude::*;
371
372    let mut kernel = MatrixXx3::<f32>::zeros(n);
373    let mut rng = rand::rng();
374    let xy_range = rand::distr::Uniform::new_inclusive(-1.0, 1.0).unwrap();
375    let z_range = rand::distr::Uniform::new_inclusive(0.0, 1.0).unwrap();
376
377    for i in 0..n {
378        let row = RowVector3::<f32>::new(
379            rng.sample(xy_range),
380            rng.sample(xy_range),
381            rng.sample(z_range),
382        );
383        // Scale to keep most samples near the center
384        let scale =
385            ((i as f32) / (kernel.nrows() as f32 - 1.0)).powi(2) * 0.9 + 0.1;
386        kernel.set_row(i, &(row * scale / row.norm()));
387    }
388    kernel
389}
390
391/// Returns an SSAO noise matrix
392///
393/// The noise matrix is a list of row vectors representing random XY rotations,
394/// which can be applied to the kernel vectors to reduce banding.
395pub fn ssao_noise(n: usize) -> OMatrix<f32, nalgebra::Dyn, Const<2>> {
396    let mut noise = MatrixXx2::<f32>::zeros(n);
397    let mut rng = rand::rng();
398    let xy_range = rand::distr::Uniform::new_inclusive(-1.0, 1.0).unwrap();
399    for i in 0..n {
400        let row =
401            RowVector2::<f32>::new(rng.sample(xy_range), rng.sample(xy_range));
402        noise.set_row(i, &(row / row.norm()));
403    }
404    noise
405}
406
407/// Converts a [`DistancePixel`] image into an RGBA bitmap
408///
409/// Filled pixels are `[255u8; 4]`; empty pixels are `[0u8; 4]` (i.e.
410/// transparent) if `transparent` is set or `[0, 0, 0, 255]` otherwise.
411pub fn to_rgba_bitmap(
412    image: Image<DistancePixel>,
413    transparent: bool,
414    threads: Option<&ThreadPool>,
415) -> Image<[u8; 4]> {
416    let mut out = Image::new(image.size());
417    out.apply_effect(
418        |x, y| {
419            let p = image[(y, x)];
420            if p.inside() {
421                [255u8; 4]
422            } else if transparent {
423                [0u8; 4]
424            } else {
425                [0, 0, 0, 255]
426            }
427        },
428        threads,
429    );
430    out
431}
432
433/// Converts a [`DistancePixel`] image into a debug visualization
434pub fn to_debug_bitmap(
435    image: Image<DistancePixel>,
436    threads: Option<&ThreadPool>,
437) -> Image<[u8; 4]> {
438    let mut out = Image::new(image.size());
439    out.apply_effect(
440        |x, y| match image[(y, x)].distance() {
441            Ok(v) => {
442                if v < 0.0 {
443                    [255u8; 4]
444                } else {
445                    [0, 0, 0, 255]
446                }
447            }
448            // Debug color scheme for fill regions
449            Err(f) => match (f.depth, f.inside) {
450                (0, true) => [255, 0, 0, 255],
451                (0, false) => [50, 0, 0, 255],
452                (1, true) => [0, 255, 0, 255],
453                (1, false) => [0, 50, 0, 255],
454                (2, true) => [0, 0, 255, 255],
455                (2, false) => [0, 0, 50, 255],
456                (_, true) => [255, 255, 0, 255],
457                (_, false) => [50, 50, 0, 255],
458            },
459        },
460        threads,
461    );
462    out
463}
464
465/// Converts a [`DistancePixel`] image to a SDF rendering
466///
467/// The shading style is pervasive on Shadertoy, so I'm not sure where it
468/// originated; I borrowed it from
469/// [Inigo Quilez](https://www.shadertoy.com/view/t3X3z4)
470///
471/// `NAN` distance pixels are shown in pure red (`[255, 0, 0]`).
472pub fn to_rgba_distance(
473    image: Image<DistancePixel>,
474    threads: Option<&ThreadPool>,
475) -> Image<[u8; 4]> {
476    let mut out = Image::new(image.size());
477    out.apply_effect(
478        |x, y| match image[(y, x)].distance() {
479            Ok(f) if f.is_nan() => [255, 0, 0, 255],
480            Ok(f) => {
481                let r = 1.0 - 0.1f32.copysign(f);
482                let g = 1.0 - 0.4f32.copysign(f);
483                let b = 1.0 - 0.7f32.copysign(f);
484
485                let dim = 1.0 - (-4.0 * f.abs()).exp(); // dimming near 0
486                let bands = 0.8 + 0.2 * (140.0 * f).cos(); // banding
487
488                let smoothstep = |edge0: f32, edge1: f32, x: f32| {
489                    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
490                    t * t * (3.0 - 2.0 * t)
491                };
492                let mix = |x: f32, y: f32, a: f32| x * (1.0 - a) + y * a;
493
494                let run = |v: f32| {
495                    let mut v = v * dim * bands;
496                    v = mix(v, 1.0, 1.0 - smoothstep(0.0, 0.015, f.abs()));
497                    v = mix(v, 1.0, 1.0 - smoothstep(0.0, 0.005, f.abs()));
498                    (v.clamp(0.0, 1.0) * 255.0) as u8
499                };
500
501                [run(r), run(g), run(b), 255]
502            }
503            // Pick a single orange / blue for fill regions
504            Err(f) => {
505                if f.inside {
506                    [184, 235, 255, 255]
507                } else {
508                    [217, 144, 72, 255]
509                }
510            }
511        },
512        threads,
513    );
514    out
515}