vello_cpu/filter/shift.rs
1// Copyright 2025 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Shared helpers for pixel-space filter operations.
5
6use vello_common::color::palette::css::TRANSPARENT;
7#[cfg(not(feature = "std"))]
8use vello_common::kurbo::common::FloatFuncs as _;
9use vello_common::peniko::color::PremulRgba8;
10use vello_common::pixmap::Pixmap;
11
12/// Shift all pixels in a pixmap by the given offset.
13///
14/// This implements the offset operation in-place by copying pixels to their new positions.
15/// The iteration order is carefully chosen based on shift direction to avoid overwriting
16/// source pixels before they're read. Areas that become exposed (due to the shift) are
17/// filled with transparent black. Pixels that would move outside the bounds are discarded.
18pub(crate) fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) {
19 let dx_pixels = dx.round() as i32;
20 let dy_pixels = dy.round() as i32;
21
22 // Early return if no offset
23 if dx_pixels == 0 && dy_pixels == 0 {
24 return;
25 }
26
27 let width = pixmap.width();
28 let height = pixmap.height();
29 let transparent = TRANSPARENT.premultiply().to_rgba8();
30
31 // Process pixels in the correct order to avoid overwriting source data.
32 // Key insight: iterate away from the direction of movement.
33 // This allows us to move pixels in-place without a temporary buffer.
34 //
35 // Use match to eliminate Box<dyn Iterator> allocation overhead and enable
36 // better compiler optimization through static dispatch.
37 match (dx_pixels >= 0, dy_pixels >= 0) {
38 (true, true) => {
39 // Shift right+down: iterate bottom-to-top, right-to-left
40 for y in (0..height).rev() {
41 for x in (0..width).rev() {
42 process_offset_pixel(
43 pixmap,
44 x,
45 y,
46 dx_pixels,
47 dy_pixels,
48 width,
49 height,
50 transparent,
51 );
52 }
53 }
54 }
55 (true, false) => {
56 // Shift right+up: iterate top-to-bottom, right-to-left
57 for y in 0..height {
58 for x in (0..width).rev() {
59 process_offset_pixel(
60 pixmap,
61 x,
62 y,
63 dx_pixels,
64 dy_pixels,
65 width,
66 height,
67 transparent,
68 );
69 }
70 }
71 }
72 (false, true) => {
73 // Shift left+down: iterate bottom-to-top, left-to-right
74 for y in (0..height).rev() {
75 for x in 0..width {
76 process_offset_pixel(
77 pixmap,
78 x,
79 y,
80 dx_pixels,
81 dy_pixels,
82 width,
83 height,
84 transparent,
85 );
86 }
87 }
88 }
89 (false, false) => {
90 // Shift left+up: iterate top-to-bottom, left-to-right
91 for y in 0..height {
92 for x in 0..width {
93 process_offset_pixel(
94 pixmap,
95 x,
96 y,
97 dx_pixels,
98 dy_pixels,
99 width,
100 height,
101 transparent,
102 );
103 }
104 }
105 }
106 }
107}
108
109/// Process a single pixel during offset operation.
110///
111/// This moves the pixel to its new position (if in bounds) and clears the source
112/// position if it's in the exposed region.
113#[inline(always)]
114fn process_offset_pixel(
115 pixmap: &mut Pixmap,
116 x: u16,
117 y: u16,
118 dx_pixels: i32,
119 dy_pixels: i32,
120 width: u16,
121 height: u16,
122 transparent: PremulRgba8,
123) {
124 let new_x = x as i32 + dx_pixels;
125 let new_y = y as i32 + dy_pixels;
126
127 if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 {
128 let pixel = pixmap.sample(x, y);
129 pixmap.set_pixel(new_x as u16, new_y as u16, pixel);
130 }
131
132 // Clear the source pixel if it's in the exposed region
133 let should_clear = (dx_pixels > 0 && x < dx_pixels as u16)
134 || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16)
135 || (dy_pixels > 0 && y < dy_pixels as u16)
136 || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16);
137
138 if should_clear {
139 pixmap.set_pixel(x, y, transparent);
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 /// Test `offset_pixels` with positive offset (right and down).
148 #[test]
149 fn test_offset_pixels_positive() {
150 let mut pixmap = Pixmap::new(4, 4);
151 // Set center pixel to white
152 pixmap.set_pixel(
153 1,
154 1,
155 PremulRgba8 {
156 r: 255,
157 g: 255,
158 b: 255,
159 a: 255,
160 },
161 );
162
163 offset_pixels(&mut pixmap, 1.0, 1.0);
164
165 // White pixel should have moved from (1,1) to (2,2)
166 let moved = pixmap.sample(2, 2);
167 assert_eq!(moved.a, 255);
168
169 // Original position should be cleared (in exposed region)
170 let cleared = pixmap.sample(1, 1);
171 assert_eq!(cleared.a, 0);
172 }
173
174 /// Test `offset_pixels` with negative offset (left and up).
175 #[test]
176 fn test_offset_pixels_negative() {
177 let mut pixmap = Pixmap::new(4, 4);
178 // Set pixel at (2,2) to white
179 pixmap.set_pixel(
180 2,
181 2,
182 PremulRgba8 {
183 r: 255,
184 g: 255,
185 b: 255,
186 a: 255,
187 },
188 );
189
190 offset_pixels(&mut pixmap, -1.0, -1.0);
191
192 // White pixel should have moved from (2,2) to (1,1)
193 let moved = pixmap.sample(1, 1);
194 assert_eq!(moved.a, 255);
195
196 // Original position should be cleared (in exposed region)
197 let cleared = pixmap.sample(2, 2);
198 assert_eq!(cleared.a, 0);
199 }
200
201 /// Test `offset_pixels` with fractional offset (should round).
202 #[test]
203 fn test_offset_pixels_fractional() {
204 let mut pixmap = Pixmap::new(4, 4);
205 pixmap.set_pixel(
206 1,
207 1,
208 PremulRgba8 {
209 r: 255,
210 g: 255,
211 b: 255,
212 a: 255,
213 },
214 );
215
216 // 0.6 rounds to 1, -0.4 rounds to 0
217 offset_pixels(&mut pixmap, 0.6, -0.4);
218
219 // Should move by (1, 0): from (1,1) to (2,1)
220 let moved = pixmap.sample(2, 1);
221 assert_eq!(moved.a, 255);
222 }
223
224 /// Test `offset_pixels` with out-of-bounds offset (should clip).
225 #[test]
226 fn test_offset_pixels_out_of_bounds() {
227 let mut pixmap = Pixmap::new(4, 4);
228 pixmap.set_pixel(
229 1,
230 1,
231 PremulRgba8 {
232 r: 255,
233 g: 255,
234 b: 255,
235 a: 255,
236 },
237 );
238
239 // Large offset that moves pixel outside bounds
240 offset_pixels(&mut pixmap, 10.0, 10.0);
241
242 // Pixel moves outside, so (1,1) should be cleared
243 let cleared = pixmap.sample(1, 1);
244 assert_eq!(cleared.a, 0);
245
246 // All pixels should be cleared (entire image is exposed region)
247 for y in 0..4 {
248 for x in 0..4 {
249 assert_eq!(pixmap.sample(x, y).a, 0);
250 }
251 }
252 }
253}