Skip to main content

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}