rustmotion_core/engine/renderer/
photo.rs1use 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}