Skip to main content

rustmotion_core/engine/renderer/
photo.rs

1use skia_safe::{
2    Canvas, CubicResampler, FilterMode, Image, Matrix, MipmapMode, Paint, Rect, SamplingOptions,
3};
4
5pub fn device_axis_scales(matrix: &Matrix) -> (f32, f32) {
6    (
7        matrix.scale_x().hypot(matrix.skew_y()),
8        matrix.scale_y().hypot(matrix.skew_x()),
9    )
10}
11
12pub fn photo_sampling(source: (f32, f32), on_screen: (f32, f32)) -> SamplingOptions {
13    let minifying = on_screen.0 < source.0 || on_screen.1 < source.1;
14    if minifying {
15        SamplingOptions::new(FilterMode::Linear, MipmapMode::Linear)
16    } else {
17        SamplingOptions::from(CubicResampler::mitchell())
18    }
19}
20
21pub fn draw_photo(canvas: &Canvas, image: impl AsRef<Image>, dst: Rect, paint: &Paint) {
22    let image = image.as_ref();
23    let (scale_x, scale_y) = device_axis_scales(&canvas.local_to_device_as_3x3());
24    let sampling = photo_sampling(
25        (image.width() as f32, image.height() as f32),
26        (dst.width() * scale_x, dst.height() * scale_y),
27    );
28    canvas.draw_image_rect_with_sampling_options(image, None, dst, sampling, paint);
29}
30
31#[cfg(test)]
32mod tests {
33    use super::*;
34    use skia_safe::{images, surfaces, AlphaType, ColorType, Data, ImageInfo};
35
36    const MIPMAPPED: SamplingOptions = SamplingOptions {
37        max_aniso: 0,
38        use_cubic: false,
39        cubic: CubicResampler { b: 0.0, c: 0.0 },
40        filter: FilterMode::Linear,
41        mipmap: MipmapMode::Linear,
42    };
43
44    fn checkerboard(size: i32) -> Image {
45        let mut pixels = Vec::with_capacity((size * size * 4) as usize);
46        for y in 0..size {
47            for x in 0..size {
48                let v = if (x + y) % 2 == 0 { 255 } else { 0 };
49                pixels.extend_from_slice(&[v, v, v, 255]);
50            }
51        }
52        let info = ImageInfo::new((size, size), ColorType::RGBA8888, AlphaType::Premul, None);
53        images::raster_from_data(&info, Data::new_copy(&pixels), (size * 4) as usize)
54            .expect("raster image from checkerboard pixels")
55    }
56
57    struct Luma {
58        mean: f64,
59        min: u8,
60        max: u8,
61    }
62
63    fn luma_inside(rgba: &[u8], side: u32, inset: u32) -> Luma {
64        let mut values = Vec::new();
65        for y in inset..side - inset {
66            for x in inset..side - inset {
67                let i = ((y * side + x) * 4) as usize;
68                let luma = 0.299 * rgba[i] as f64
69                    + 0.587 * rgba[i + 1] as f64
70                    + 0.114 * rgba[i + 2] as f64;
71                values.push(luma.round() as u8);
72            }
73        }
74        Luma {
75            mean: values.iter().map(|v| *v as f64).sum::<f64>() / values.len() as f64,
76            min: *values.iter().min().expect("non-empty region"),
77            max: *values.iter().max().expect("non-empty region"),
78        }
79    }
80
81    fn draw_checkerboard(side: u32, canvas_scale: f32, through_helper: bool) -> Vec<u8> {
82        let info = ImageInfo::new(
83            (side as i32, side as i32),
84            ColorType::RGBA8888,
85            AlphaType::Premul,
86            None,
87        );
88        let mut surface = surfaces::raster(&info, None, None).expect("raster surface");
89        let source = checkerboard(640);
90        let local_side = side as f32 / canvas_scale;
91        let dst = Rect::from_xywh(0.0, 0.0, local_side, local_side);
92        {
93            let canvas = surface.canvas();
94            canvas.scale((canvas_scale, canvas_scale));
95            if through_helper {
96                draw_photo(canvas, &source, dst, &Paint::default());
97            } else {
98                canvas.draw_image_rect(&source, None, dst, &Paint::default());
99            }
100        }
101        let mut rgba = vec![0u8; (side * side * 4) as usize];
102        assert!(surface.read_pixels(&info, &mut rgba, (side * 4) as usize, (0, 0)));
103        rgba
104    }
105
106    #[test]
107    fn minifying_either_axis_asks_for_mipmaps() {
108        assert_eq!(photo_sampling((640.0, 640.0), (64.0, 64.0)), MIPMAPPED);
109        assert_eq!(photo_sampling((640.0, 640.0), (64.0, 900.0)), MIPMAPPED);
110        assert_eq!(photo_sampling((640.0, 640.0), (900.0, 64.0)), MIPMAPPED);
111    }
112
113    #[test]
114    fn drawing_at_or_above_source_size_asks_for_cubic() {
115        let at_source = photo_sampling((640.0, 640.0), (640.0, 640.0));
116        let enlarged = photo_sampling((640.0, 640.0), (1280.0, 1280.0));
117        assert!(at_source.use_cubic, "1:1 must not sample with mipmaps");
118        assert!(enlarged.use_cubic, "an enlargement must not sample mipmaps");
119    }
120
121    #[test]
122    fn axis_scales_ignore_rotation() {
123        let (sx, sy) = device_axis_scales(&Matrix::scale((2.0, 3.0)));
124        assert!((sx - 2.0).abs() < 1e-5 && (sy - 3.0).abs() < 1e-5);
125
126        let mut rotated = Matrix::new_identity();
127        rotated.set_rotate(90.0, None);
128        let (rx, ry) = device_axis_scales(&rotated);
129        assert!(
130            (rx - 1.0).abs() < 1e-5 && (ry - 1.0).abs() < 1e-5,
131            "a pure rotation is not a shrink, got ({rx}, {ry})"
132        );
133    }
134
135    #[test]
136    fn a_checkerboard_shrunk_to_a_tenth_averages_to_grey() {
137        let before = luma_inside(&draw_checkerboard(64, 1.0, false), 64, 4);
138        let after = luma_inside(&draw_checkerboard(64, 1.0, true), 64, 4);
139
140        assert_eq!(
141            (before.min, before.max),
142            (255, 255),
143            "the nearest-neighbour path is expected to read solid white at scale 1/10, \
144             got min {} max {}",
145            before.min,
146            before.max
147        );
148        assert!(
149            (after.mean - 127.0).abs() < 2.0 && after.min > 120 && after.max < 135,
150            "a 1px checkerboard at scale 1/10 must average to grey, got mean {:.1} \
151             min {} max {}",
152            after.mean,
153            after.min,
154            after.max
155        );
156    }
157
158    #[test]
159    fn a_checkerboard_at_an_awkward_scale_averages_to_grey() {
160        let before = luma_inside(&draw_checkerboard(120, 1.0, false), 120, 4);
161        let after = luma_inside(&draw_checkerboard(120, 1.0, true), 120, 4);
162
163        assert!(
164            before.min == 0 && before.max == 255,
165            "the nearest-neighbour path is expected to read black-and-white noise, \
166             got min {} max {}",
167            before.min,
168            before.max
169        );
170        assert!(
171            (after.mean - 127.0).abs() < 2.0 && after.min > 120 && after.max < 135,
172            "a 1px checkerboard at scale 120/640 must average to grey, got mean {:.1} \
173             min {} max {}",
174            after.mean,
175            after.min,
176            after.max
177        );
178    }
179
180    #[test]
181    fn a_camera_zoom_out_is_read_as_a_shrink() {
182        let full_size_rect = luma_inside(&draw_checkerboard(64, 0.1, true), 64, 4);
183        assert!(
184            (full_size_rect.mean - 127.0).abs() < 2.0 && full_size_rect.max < 135,
185            "a 640px rect on a canvas scaled to 0.1 is drawn at 64px and must sample as a \
186             shrink, got mean {:.1} max {}",
187            full_size_rect.mean,
188            full_size_rect.max
189        );
190    }
191}