Skip to main content

fidget_raster/
effects.rs

1//! Post-processing effects for rendered images
2
3use super::{
4    ColorImage, Image, ImageSize, ThreadPool, pixel, pixel::DistancePixel,
5    voxel,
6};
7use fidget_core::rng::mix;
8use nalgebra::{
9    Const, Matrix2xX, Matrix3, Matrix3xX, OMatrix, Vector2, 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: &voxel::Image,
19    threads: Option<&ThreadPool>,
20) -> voxel::Image {
21    let radius = 2;
22    let mut out = voxel::Image::new(image.size());
23    out.apply_effect(
24        |x, y| {
25            let depth = image[(y, x)].depth;
26            let normal = if depth > 0 {
27                denoise_pixel(image, x, y, radius)
28            } else {
29                [0.0; 3]
30            };
31            voxel::GeometryPixel { depth, normal }
32        },
33        threads,
34    );
35    out
36}
37
38/// Applies shading to an image (including optional SSAO)
39///
40/// # Panics
41/// If the images have different widths or heights
42pub fn apply_shading(
43    image: &voxel::Image,
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 {
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: &voxel::Image,
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 {
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: &voxel::Image,
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 as f32 / 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: &voxel::Image,
159    x: usize,
160    y: usize,
161    kernel: &OMatrix<f32, Const<3>, nalgebra::Dyn>,
162    noise: &OMatrix<f32, Const<2>, nalgebra::Dyn>,
163) -> f32 {
164    let pos = (y, x);
165    let voxel::GeometryPixel {
166        normal: [nx, ny, nz],
167        depth: d,
168    } = image[pos];
169
170    if d == 0 {
171        return f32::NAN;
172    }
173
174    // Compute a scale which compensates for image aspect ratio
175    let scale_min = image
176        .size
177        .width()
178        .min(image.size.height())
179        .min(image.size.depth()) as f32;
180    let (scale_x, scale_y, scale_z) = (
181        scale_min / image.size.width() as f32,
182        scale_min / image.size.height() as f32,
183        scale_min / image.size.depth() as f32,
184    );
185
186    // XY pixel offset prevents a small bias.  You can test with a sphere / Z
187    // plane union to see the difference if you're curious; without the offset,
188    // one quadrant of the sphere will be darker than the others.
189    let p = Vector3::new(
190        (((x as f32 + 0.5) / image.width() as f32) - 0.5) * 2.0,
191        (((y as f32 + 0.5) / image.height() as f32) - 0.5) * 2.0,
192        ((d as f32 / image.depth() as f32) - 0.5) * 2.0,
193    );
194
195    // Get normal from the image
196    let n = Vector3::new(nx, ny, nz).normalize();
197
198    // Get a rotation vector based on hashed pixel index, and add a Z coordinate
199    let rvec =
200        noise.column(mix(pos.0 as u32, pos.1 as u32) as usize % noise.ncols());
201    let rvec = Vector3::new(rvec.x, rvec.y, 0.0);
202
203    // Build our transform matrix, using the Gram-Schmidt process
204    let tangent = (rvec - n * rvec.dot(&n)).normalize();
205    let bitangent = n.cross(&tangent);
206    let tbn = Matrix3::from_columns(&[tangent, bitangent, n]);
207
208    const RADIUS: f32 = 0.1;
209    let mut occlusion = 0.0;
210    for i in 0..kernel.ncols() {
211        // offset in world coordinates (with compensation for aspect ratio)
212        let mut offset = tbn * kernel.column(i) * RADIUS;
213        offset.x *= scale_x;
214        offset.y *= scale_y;
215        offset.z *= scale_z;
216
217        // position in world coordinates
218        let sample_pos = offset + p;
219
220        // convert to pixel coordinates
221        let px = ((sample_pos.x / 2.0) + 0.5) * image.width() as f32;
222        let py = ((sample_pos.y / 2.0) + 0.5) * image.height() as f32;
223
224        // Get depth from heightmap
225        let actual_h = if px < image.width() as f32
226            && py < image.height() as f32
227            && px > 0.0
228            && py > 0.0
229        {
230            image[(py as usize, px as usize)].depth
231        } else {
232            0
233        };
234
235        let actual_z = ((actual_h as f32 / image.depth() as f32) - 0.5) * 2.0;
236
237        let dz = sample_pos.z - actual_z;
238        if dz < RADIUS {
239            occlusion += (sample_pos.z <= actual_z) as usize as f32;
240        } else if dz < RADIUS * 2.0 && sample_pos.z <= actual_z {
241            occlusion += ((RADIUS - (dz - RADIUS)) / RADIUS).powi(2);
242        }
243    }
244    1.0 - (occlusion / kernel.ncols() as f32)
245}
246
247/// If the pixel has a back-facing normal, then pick a normal from neighbors
248fn denoise_pixel(
249    image: &voxel::Image,
250    x: usize,
251    y: usize,
252    denoise_radius: isize,
253) -> [f32; 3] {
254    let n = image[(y, x)].normal;
255    if n[2] > 0.0 {
256        return n;
257    }
258    let x = x as isize;
259    let y = y as isize;
260    [
261        (0, 0),
262        (-denoise_radius, 0),
263        (0, -denoise_radius),
264        (-denoise_radius, -denoise_radius),
265    ]
266    .into_iter()
267    .flat_map(|(xmin, ymin)| {
268        let mut sum = Vector3::zeros();
269        let mut count = 0;
270        for i in 0..=denoise_radius {
271            for j in 0..=denoise_radius {
272                let tx = x + xmin + i;
273                let ty = y + ymin + j;
274                if tx >= 0
275                    && ty >= 0
276                    && (tx as usize) < image.width()
277                    && (ty as usize) < image.height()
278                {
279                    let pos = (ty as usize, tx as usize);
280                    if image[pos].depth != 0 && image[pos].normal[2] > 0.0 {
281                        let n = image[pos].normal;
282                        sum += Vector3::new(n[0], n[1], n[2]);
283                        count += 1;
284                    }
285                }
286            }
287        }
288        if count == 0 {
289            return None;
290        }
291        let mean = sum / count as f32;
292        let mut score = 0.0;
293        for i in 0..=denoise_radius {
294            for j in 0..=denoise_radius {
295                let tx = x + xmin + i;
296                let ty = y + ymin + j;
297                if tx >= 0
298                    && ty >= 0
299                    && (tx as usize) < image.width()
300                    && (ty as usize) < image.height()
301                {
302                    let pos = (ty as usize, tx as usize);
303                    if image[pos].depth != 0 {
304                        let n = image[pos].normal;
305                        score += Vector3::new(n[0], n[1], n[2]).dot(&mean);
306                    }
307                }
308            }
309        }
310        Some((score, mean.into()))
311    })
312    .max_by_key(|(score, _mean)| ordered_float::OrderedFloat(*score))
313    .unwrap_or((0.0, n))
314    .1
315}
316
317/// Computes a single blurred pixel
318fn compute_pixel_blur(
319    ssao: &Image<f32>,
320    x: usize,
321    y: usize,
322    blur_radius: isize,
323) -> f32 {
324    let x = x as isize;
325    let y = y as isize;
326    [
327        (0, 0),
328        (-blur_radius, 0),
329        (0, -blur_radius),
330        (-blur_radius, -blur_radius),
331    ]
332    .into_iter()
333    .flat_map(|(xmin, ymin)| {
334        let mut sum = 0.0;
335        let mut count = 0;
336        for i in 0..=blur_radius {
337            for j in 0..=blur_radius {
338                let tx = x + xmin + i;
339                let ty = y + ymin + j;
340                if tx >= 0
341                    && ty >= 0
342                    && (tx as usize) < ssao.width()
343                    && (ty as usize) < ssao.height()
344                {
345                    let s = ssao[(ty as usize, tx as usize)];
346                    if !s.is_nan() {
347                        sum += s;
348                        count += 1;
349                    }
350                }
351            }
352        }
353        if count == 0 {
354            return None;
355        }
356        let mean = sum / count as f32;
357        let mut stdev = 0.0;
358        for i in 0..=blur_radius {
359            for j in 0..=blur_radius {
360                let tx = x + xmin + i;
361                let ty = y + ymin + j;
362                if tx >= 0
363                    && ty >= 0
364                    && (tx as usize) < ssao.width()
365                    && (ty as usize) < ssao.height()
366                {
367                    let s = ssao[(ty as usize, tx as usize)];
368                    if !s.is_nan() {
369                        stdev += (mean - s).powi(2);
370                    }
371                }
372            }
373        }
374        Some((stdev / count as f32, mean))
375    })
376    .min_by_key(|(stdev, _mean)| ordered_float::OrderedFloat(*stdev))
377    .unwrap_or_else(|| (0.0, ssao[(y as usize, x as usize)]))
378    .1
379}
380
381/// Returns a SSAO kernel matrix
382///
383/// The resulting matrix is a list of row vectors representing points in a
384/// hemisphere with a maximum radius of 1.0, used for sampling the depth buffer.
385///
386/// It should be reoriented based on the surface normal.
387pub fn ssao_kernel(n: usize) -> OMatrix<f32, Const<3>, nalgebra::Dyn> {
388    // Based on http://john-chapman-graphics.blogspot.com/2013/01/ssao-tutorial.html
389    use rand::prelude::*;
390
391    let mut kernel = Matrix3xX::<f32>::zeros(n);
392    let mut rng = rand::rng();
393    let xy_range = rand::distr::Uniform::new_inclusive(-1.0, 1.0).unwrap();
394    let z_range = rand::distr::Uniform::new_inclusive(0.0, 1.0).unwrap();
395
396    for i in 0..n {
397        loop {
398            let row = Vector3::<f32>::new(
399                rng.sample(xy_range),
400                rng.sample(xy_range),
401                rng.sample(z_range),
402            );
403            // Rejection sampling to ensure even distribution
404            if row.norm() < 1.0 && row.norm() > f32::EPSILON {
405                // Scale to keep most samples near the center
406                let scale =
407                    ((i as f32) / (kernel.ncols() as f32 - 1.0)).powi(2) * 0.9
408                        + 0.1;
409                kernel.set_column(i, &(row * scale / row.norm()));
410                break;
411            }
412        }
413    }
414    kernel
415}
416
417/// Returns an SSAO noise matrix
418///
419/// The noise matrix is a list of row vectors representing random XY rotations,
420/// which can be applied to the kernel vectors to reduce banding.
421pub fn ssao_noise(n: usize) -> OMatrix<f32, Const<2>, nalgebra::Dyn> {
422    let mut noise = Matrix2xX::<f32>::zeros(n);
423    let mut rng = rand::rng();
424    let xy_range = rand::distr::Uniform::new_inclusive(-1.0, 1.0).unwrap();
425    for i in 0..n {
426        loop {
427            let row =
428                Vector2::<f32>::new(rng.sample(xy_range), rng.sample(xy_range));
429            // Rejection sampling to ensure even distribution
430            if row.norm() < 1.0 && row.norm() > f32::EPSILON {
431                noise.set_column(i, &(row / row.norm()));
432                break;
433            }
434        }
435    }
436    noise
437}
438
439/// Converts a [`DistancePixel`] image into an RGBA bitmap
440///
441/// Filled pixels are `[255u8; 4]`; empty pixels are `[0u8; 4]` (i.e.
442/// transparent) if `transparent` is set or `[0, 0, 0, 255]` otherwise.
443pub fn to_rgba_bitmap(
444    image: pixel::Image,
445    transparent: bool,
446    threads: Option<&ThreadPool>,
447) -> Image<[u8; 4]> {
448    let mut out = Image::new(image.size());
449    out.apply_effect(
450        |x, y| {
451            let p = image[(y, x)];
452            if p.inside() {
453                [255u8; 4]
454            } else if transparent {
455                [0u8; 4]
456            } else {
457                [0, 0, 0, 255]
458            }
459        },
460        threads,
461    );
462    out
463}
464
465/// Converts a [`DistancePixel`] image into a debug visualization
466pub fn to_debug_bitmap(
467    image: pixel::Image,
468    threads: Option<&ThreadPool>,
469) -> Image<[u8; 4]> {
470    let mut out = Image::new(image.size());
471    out.apply_effect(
472        |x, y| match image[(y, x)].unpack() {
473            DistancePixel::Value(v) => {
474                if v < 0.0 {
475                    [255u8; 4]
476                } else {
477                    [0, 0, 0, 255]
478                }
479            }
480            // Debug color scheme for fill regions
481            DistancePixel::Fill { depth, inside } => match (depth, inside) {
482                (0, true) => [255, 0, 0, 255],
483                (0, false) => [50, 0, 0, 255],
484                (1, true) => [0, 255, 0, 255],
485                (1, false) => [0, 50, 0, 255],
486                (2, true) => [0, 0, 255, 255],
487                (2, false) => [0, 0, 50, 255],
488                (_, true) => [255, 255, 0, 255],
489                (_, false) => [50, 50, 0, 255],
490            },
491        },
492        threads,
493    );
494    out
495}
496
497/// Converts a [`DistancePixel`] image to a SDF rendering
498///
499/// The shading style is pervasive on Shadertoy, so I'm not sure where it
500/// originated; I borrowed it from
501/// [Inigo Quilez](https://www.shadertoy.com/view/t3X3z4)
502///
503/// `NAN` distance pixels are shown in pure red (`[255, 0, 0]`).
504pub fn to_rgba_distance(
505    image: pixel::Image,
506    threads: Option<&ThreadPool>,
507) -> Image<[u8; 4]> {
508    let mut out = Image::new(image.size());
509    out.apply_effect(
510        |x, y| match image[(y, x)].unpack() {
511            DistancePixel::Value(f) if f.is_nan() => [255, 0, 0, 255],
512            DistancePixel::Value(f) => {
513                let r = 1.0 - 0.1f32.copysign(f);
514                let g = 1.0 - 0.4f32.copysign(f);
515                let b = 1.0 - 0.7f32.copysign(f);
516
517                let dim = 1.0 - (-4.0 * f.abs()).exp(); // dimming near 0
518                let bands = 0.8 + 0.2 * (140.0 * f).cos(); // banding
519
520                let smoothstep = |edge0: f32, edge1: f32, x: f32| {
521                    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
522                    t * t * (3.0 - 2.0 * t)
523                };
524                let mix = |x: f32, y: f32, a: f32| x * (1.0 - a) + y * a;
525
526                let run = |v: f32| {
527                    let mut v = v * dim * bands;
528                    v = mix(v, 1.0, 1.0 - smoothstep(0.0, 0.015, f.abs()));
529                    v = mix(v, 1.0, 1.0 - smoothstep(0.0, 0.005, f.abs()));
530                    (v.clamp(0.0, 1.0) * 255.0) as u8
531                };
532
533                [run(r), run(g), run(b), 255]
534            }
535            // Pick a single orange / blue for fill regions
536            DistancePixel::Fill { inside, .. } => {
537                if inside {
538                    [184, 235, 255, 255]
539                } else {
540                    [217, 144, 72, 255]
541                }
542            }
543        },
544        threads,
545    );
546    out
547}