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