vello_cpu(filters): Add offset filter (#1351)
* Implement `FilterPrimitive::Offset` bounds expansion in `vello_common`
* Add `vello_cpu` `Offset` filter using shared in-place pixel shift
helper
* Refactor `DropShadow` to reuse shared shift code
* Add `vello_sparse_tests` coverage + reference snapshot for offset
---------
Co-authored-by: Thomas Churchman <thomas@kepow.org>
diff --git a/sparse_strips/vello_common/src/filter_effects.rs b/sparse_strips/vello_common/src/filter_effects.rs
index 72f9eab..2362d9d 100644
--- a/sparse_strips/vello_common/src/filter_effects.rs
+++ b/sparse_strips/vello_common/src/filter_effects.rs
@@ -20,6 +20,7 @@
//! - `Flood` - Solid color fill
//! - `GaussianBlur` - Gaussian blur filter
//! - `DropShadow` - Drop shadow effect (compound primitive)
+//! - `Offset` - Translation/shift (single primitive)
//!
//! **Note:** Currently only single primitive filters are supported. Filter graphs with
//! multiple connected primitives are not yet implemented.
@@ -36,7 +37,6 @@
//!
//! **Filter Primitives:**
//! - `ColorMatrix` - Matrix-based color transformation
-//! - `Offset` - Geometric offset/translation
//! - `Composite` - Porter-Duff compositing operations
//! - `Blend` - Blend mode operations
//! - `Morphology` - Dilate/erode operations
@@ -569,6 +569,12 @@
let radius = (*std_deviation * 3.0) as f64;
Rect::new(-radius, -radius, radius, radius)
}
+ Self::Offset { dx, dy } => {
+ // Offset shifts pixels; expand bounds asymmetrically so shifted content isn't cut.
+ let dx = *dx as f64;
+ let dy = *dy as f64;
+ Rect::new(dx.min(0.0), dy.min(0.0), dx.max(0.0), dy.max(0.0))
+ }
Self::DropShadow {
std_deviation,
dx,
@@ -594,6 +600,22 @@
}
}
+#[cfg(test)]
+mod offset_expansion_tests {
+ use super::FilterPrimitive;
+ use crate::kurbo::Rect;
+
+ #[test]
+ fn offset_expands_in_direction_of_shift() {
+ let p = FilterPrimitive::Offset { dx: 2.5, dy: -3.0 };
+ assert_eq!(
+ p.expansion_rect(),
+ Rect::new(0.0, -3.0, 2.5, 0.0),
+ "Offset expansion should be asymmetric and include the shift vector"
+ );
+ }
+}
+
/// Unique identifier for a filter primitive in the graph.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct FilterId(pub u16);
diff --git a/sparse_strips/vello_cpu/CHANGELOG.md b/sparse_strips/vello_cpu/CHANGELOG.md
index 24450b9..f5442ce 100644
--- a/sparse_strips/vello_cpu/CHANGELOG.md
+++ b/sparse_strips/vello_cpu/CHANGELOG.md
@@ -15,6 +15,10 @@
This release has an [MSRV][] of 1.88.
+### Added
+
+- Support for the "offset" filter has been added ([#1351] by [@waywardmonkeys])
+
### Changed
- Breaking change: Updated Peniko to [v0.6.0](https://github.com/linebender/peniko/releases/tag/v0.6.0). ([#1349][] by [@DJMcNab][])
@@ -83,6 +87,7 @@
[@LaurenzV]: https://github.com/LaurenzV
[@grebmeg]: https://github.com/grebmeg
[@nicoburns]: https://github.com/nicoburns
+[@waywardmonkeys]: https://github.com/waywardmonkeys
[#1159]: https://github.com/linebender/vello/pull/1159
[#1203]: https://github.com/linebender/vello/pull/1203
@@ -91,6 +96,7 @@
[#1294]: https://github.com/linebender/vello/pull/1294
[#1327]: https://github.com/linebender/vello/pull/1327
[#1349]: https://github.com/linebender/vello/pull/1349
+[#1351]: https://github.com/linebender/vello/pull/1351
[Unreleased]: https://github.com/linebender/fearless_simd/compare/sparse-strips-v0.0.5...HEAD
[0.0.5]: https://github.com/linebender/vello/compare/sparse-stips-v0.0.4...sparse-strips-v0.0.5
diff --git a/sparse_strips/vello_cpu/src/filter/drop_shadow.rs b/sparse_strips/vello_cpu/src/filter/drop_shadow.rs
index 1e6a4e4..12f10c3 100644
--- a/sparse_strips/vello_cpu/src/filter/drop_shadow.rs
+++ b/sparse_strips/vello_cpu/src/filter/drop_shadow.rs
@@ -15,8 +15,8 @@
use super::FilterEffect;
use super::gaussian_blur::{MAX_KERNEL_SIZE, apply_blur, plan_decimated_blur};
+use super::shift::offset_pixels;
use crate::layer_manager::LayerManager;
-use vello_common::color::palette::css::TRANSPARENT;
use vello_common::color::{AlphaColor, Srgb};
use vello_common::filter_effects::EdgeMode;
use vello_common::peniko::color::PremulRgba8;
@@ -130,137 +130,6 @@
compose_shadow_direct(&shadow_pixmap, pixmap, color);
}
-/// Shift all pixels in a pixmap by the given offset.
-///
-/// This implements the offset operation in-place by copying pixels to their new positions.
-/// The iteration order is carefully chosen based on shift direction to avoid overwriting
-/// source pixels before they're read. Areas that become exposed (due to the shift) are
-/// filled with transparent black. Pixels that would move outside the bounds are discarded.
-fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) {
- let dx_pixels = dx.round() as i32;
- let dy_pixels = dy.round() as i32;
-
- // Early return if no offset
- if dx_pixels == 0 && dy_pixels == 0 {
- return;
- }
-
- let width = pixmap.width();
- let height = pixmap.height();
- let transparent = TRANSPARENT.premultiply().to_rgba8();
-
- // Process pixels in the correct order to avoid overwriting source data.
- // Key insight: iterate away from the direction of movement.
- // This allows us to move pixels in-place without a temporary buffer.
- //
- // Use match to eliminate Box<dyn Iterator> allocation overhead and enable
- // better compiler optimization through static dispatch.
- match (dx_pixels >= 0, dy_pixels >= 0) {
- (true, true) => {
- // Shift right+down: iterate bottom-to-top, right-to-left
- for y in (0..height).rev() {
- for x in (0..width).rev() {
- process_offset_pixel(
- pixmap,
- x,
- y,
- dx_pixels,
- dy_pixels,
- width,
- height,
- transparent,
- );
- }
- }
- }
- (true, false) => {
- // Shift right+up: iterate top-to-bottom, right-to-left
- for y in 0..height {
- for x in (0..width).rev() {
- process_offset_pixel(
- pixmap,
- x,
- y,
- dx_pixels,
- dy_pixels,
- width,
- height,
- transparent,
- );
- }
- }
- }
- (false, true) => {
- // Shift left+down: iterate bottom-to-top, left-to-right
- for y in (0..height).rev() {
- for x in 0..width {
- process_offset_pixel(
- pixmap,
- x,
- y,
- dx_pixels,
- dy_pixels,
- width,
- height,
- transparent,
- );
- }
- }
- }
- (false, false) => {
- // Shift left+up: iterate top-to-bottom, left-to-right
- for y in 0..height {
- for x in 0..width {
- process_offset_pixel(
- pixmap,
- x,
- y,
- dx_pixels,
- dy_pixels,
- width,
- height,
- transparent,
- );
- }
- }
- }
- }
-}
-
-/// Process a single pixel during offset operation.
-///
-/// This moves the pixel to its new position (if in bounds) and clears the source
-/// position if it's in the exposed region.
-#[inline(always)]
-fn process_offset_pixel(
- pixmap: &mut Pixmap,
- x: u16,
- y: u16,
- dx_pixels: i32,
- dy_pixels: i32,
- width: u16,
- height: u16,
- transparent: PremulRgba8,
-) {
- let new_x = x as i32 + dx_pixels;
- let new_y = y as i32 + dy_pixels;
-
- if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 {
- let pixel = pixmap.sample(x, y);
- pixmap.set_pixel(new_x as u16, new_y as u16, pixel);
- }
-
- // Clear the source pixel if it's in the exposed region
- let should_clear = (dx_pixels > 0 && x < dx_pixels as u16)
- || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16)
- || (dy_pixels > 0 && y < dy_pixels as u16)
- || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16);
-
- if should_clear {
- pixmap.set_pixel(x, y, transparent);
- }
-}
-
/// Apply shadow color and composite with original.
///
/// The shadow has already been offset and blurred, so this simply applies
@@ -451,113 +320,6 @@
);
}
- /// Test `offset_pixels` with positive offset (right and down).
- #[test]
- fn test_offset_pixels_positive() {
- let mut pixmap = Pixmap::new(4, 4);
- // Set center pixel to white
- pixmap.set_pixel(
- 1,
- 1,
- PremulRgba8 {
- r: 255,
- g: 255,
- b: 255,
- a: 255,
- },
- );
-
- offset_pixels(&mut pixmap, 1.0, 1.0);
-
- // White pixel should have moved from (1,1) to (2,2)
- let moved = pixmap.sample(2, 2);
- assert_eq!(moved.a, 255);
-
- // Original position should be cleared (in exposed region)
- let cleared = pixmap.sample(1, 1);
- assert_eq!(cleared.a, 0);
- }
-
- /// Test `offset_pixels` with negative offset (left and up).
- #[test]
- fn test_offset_pixels_negative() {
- let mut pixmap = Pixmap::new(4, 4);
- // Set pixel at (2,2) to white
- pixmap.set_pixel(
- 2,
- 2,
- PremulRgba8 {
- r: 255,
- g: 255,
- b: 255,
- a: 255,
- },
- );
-
- offset_pixels(&mut pixmap, -1.0, -1.0);
-
- // White pixel should have moved from (2,2) to (1,1)
- let moved = pixmap.sample(1, 1);
- assert_eq!(moved.a, 255);
-
- // Original position should be cleared (in exposed region)
- let cleared = pixmap.sample(2, 2);
- assert_eq!(cleared.a, 0);
- }
-
- /// Test `offset_pixels` with fractional offset (should round).
- #[test]
- fn test_offset_pixels_fractional() {
- let mut pixmap = Pixmap::new(4, 4);
- pixmap.set_pixel(
- 1,
- 1,
- PremulRgba8 {
- r: 255,
- g: 255,
- b: 255,
- a: 255,
- },
- );
-
- // 0.6 rounds to 1, -0.4 rounds to 0
- offset_pixels(&mut pixmap, 0.6, -0.4);
-
- // Should move by (1, 0): from (1,1) to (2,1)
- let moved = pixmap.sample(2, 1);
- assert_eq!(moved.a, 255);
- }
-
- /// Test `offset_pixels` with out-of-bounds offset (should clip).
- #[test]
- fn test_offset_pixels_out_of_bounds() {
- let mut pixmap = Pixmap::new(4, 4);
- pixmap.set_pixel(
- 1,
- 1,
- PremulRgba8 {
- r: 255,
- g: 255,
- b: 255,
- a: 255,
- },
- );
-
- // Large offset that moves pixel outside bounds
- offset_pixels(&mut pixmap, 10.0, 10.0);
-
- // Pixel moves outside, so (1,1) should be cleared
- let cleared = pixmap.sample(1, 1);
- assert_eq!(cleared.a, 0);
-
- // All pixels should be cleared (entire image is exposed region)
- for y in 0..4 {
- for x in 0..4 {
- assert_eq!(pixmap.sample(x, y).a, 0);
- }
- }
- }
-
/// Test `compose_shadow_direct` applies color correctly.
#[test]
fn test_compose_shadow_color() {
diff --git a/sparse_strips/vello_cpu/src/filter/mod.rs b/sparse_strips/vello_cpu/src/filter/mod.rs
index ad0c8f7..674d1eb 100644
--- a/sparse_strips/vello_cpu/src/filter/mod.rs
+++ b/sparse_strips/vello_cpu/src/filter/mod.rs
@@ -11,10 +11,13 @@
mod drop_shadow;
mod flood;
mod gaussian_blur;
+mod offset;
+mod shift;
pub(crate) use drop_shadow::DropShadow;
pub(crate) use flood::Flood;
pub(crate) use gaussian_blur::GaussianBlur;
+pub(crate) use offset::Offset;
use crate::layer_manager::LayerManager;
use vello_common::filter_effects::{Filter, FilterPrimitive};
@@ -96,6 +99,10 @@
DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color);
drop_shadow.execute_lowp(pixmap, layer_manager);
}
+ FilterPrimitive::Offset { dx, dy } => {
+ let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, &transform);
+ Offset::new(scaled_dx, scaled_dy).execute_lowp(pixmap, layer_manager);
+ }
_ => {
// Other primitives like Blend, ColorMatrix, ComponentTransfer, etc.
// are not yet implemented
@@ -155,6 +162,10 @@
DropShadow::new(scaled_dx, scaled_dy, scaled_std_dev, *edge_mode, *color);
drop_shadow.execute_highp(pixmap, layer_manager);
}
+ FilterPrimitive::Offset { dx, dy } => {
+ let (scaled_dx, scaled_dy) = transform_offset_params(*dx, *dy, &transform);
+ Offset::new(scaled_dx, scaled_dy).execute_highp(pixmap, layer_manager);
+ }
_ => {
// Other primitives like Blend, ColorMatrix, ComponentTransfer, etc.
// are not yet implemented
@@ -163,6 +174,17 @@
}
}
+/// Transform an offset's dx/dy using the affine transformation's linear part.
+///
+/// # Returns
+/// A tuple of (`scaled_dx`, `scaled_dy`) in device space.
+fn transform_offset_params(dx: f32, dy: f32, transform: &Affine) -> (f32, f32) {
+ let offset = Vec2::new(dx as f64, dy as f64);
+ let [a, b, c, d, _, _] = transform.as_coeffs();
+ let transformed_offset = Vec2::new(a * offset.x + c * offset.y, b * offset.x + d * offset.y);
+ (transformed_offset.x as f32, transformed_offset.y as f32)
+}
+
/// Transform a drop shadow's offset and standard deviation using the affine transformation.
///
/// Applies the full linear transformation (rotation, scale, and shear) to the offset vector,
diff --git a/sparse_strips/vello_cpu/src/filter/offset.rs b/sparse_strips/vello_cpu/src/filter/offset.rs
new file mode 100644
index 0000000..fc2924a
--- /dev/null
+++ b/sparse_strips/vello_cpu/src/filter/offset.rs
@@ -0,0 +1,62 @@
+// Copyright 2026 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! `feOffset` filter primitive implementation.
+
+use vello_common::pixmap::Pixmap;
+
+use super::FilterEffect;
+use super::shift::offset_pixels;
+use crate::layer_manager::LayerManager;
+
+/// Translation/shift filter.
+///
+/// This shifts the input image by `(dx, dy)` in device pixel space.
+#[derive(Clone, Copy, Debug)]
+pub(crate) struct Offset {
+ dx: f32,
+ dy: f32,
+}
+
+impl Offset {
+ pub(crate) fn new(dx: f32, dy: f32) -> Self {
+ Self { dx, dy }
+ }
+
+ fn execute(self, pixmap: &mut Pixmap, _layer_manager: &mut LayerManager) {
+ offset_pixels(pixmap, self.dx, self.dy);
+ }
+}
+
+impl FilterEffect for Offset {
+ fn execute_lowp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) {
+ self.execute(pixmap, layer_manager);
+ }
+
+ fn execute_highp(&self, pixmap: &mut Pixmap, layer_manager: &mut LayerManager) {
+ self.execute(pixmap, layer_manager);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::Offset;
+ use crate::filter::FilterEffect;
+ use crate::layer_manager::LayerManager;
+ use vello_common::peniko::color::PremulRgba8;
+ use vello_common::pixmap::Pixmap;
+
+ #[test]
+ fn offset_moves_pixels_and_clears_uncovered_area() {
+ let mut layer_manager = LayerManager::new();
+ let mut pixmap = Pixmap::new(4, 3);
+ pixmap.set_pixel(1, 1, PremulRgba8::from_u32(0xff_00_00_ff)); // premul red, opaque
+
+ Offset::new(2.0, -1.0).execute_lowp(&mut pixmap, &mut layer_manager);
+
+ // Original pixel (1,1) moved to (3,0).
+ assert_eq!(pixmap.sample(3, 0), PremulRgba8::from_u32(0xff_00_00_ff));
+ // Original location cleared.
+ assert_eq!(pixmap.sample(1, 1), PremulRgba8::from_u32(0));
+ }
+}
diff --git a/sparse_strips/vello_cpu/src/filter/shift.rs b/sparse_strips/vello_cpu/src/filter/shift.rs
new file mode 100644
index 0000000..8266079
--- /dev/null
+++ b/sparse_strips/vello_cpu/src/filter/shift.rs
@@ -0,0 +1,253 @@
+// Copyright 2025 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Shared helpers for pixel-space filter operations.
+
+use vello_common::color::palette::css::TRANSPARENT;
+#[cfg(not(feature = "std"))]
+use vello_common::kurbo::common::FloatFuncs as _;
+use vello_common::peniko::color::PremulRgba8;
+use vello_common::pixmap::Pixmap;
+
+/// Shift all pixels in a pixmap by the given offset.
+///
+/// This implements the offset operation in-place by copying pixels to their new positions.
+/// The iteration order is carefully chosen based on shift direction to avoid overwriting
+/// source pixels before they're read. Areas that become exposed (due to the shift) are
+/// filled with transparent black. Pixels that would move outside the bounds are discarded.
+pub(crate) fn offset_pixels(pixmap: &mut Pixmap, dx: f32, dy: f32) {
+ let dx_pixels = dx.round() as i32;
+ let dy_pixels = dy.round() as i32;
+
+ // Early return if no offset
+ if dx_pixels == 0 && dy_pixels == 0 {
+ return;
+ }
+
+ let width = pixmap.width();
+ let height = pixmap.height();
+ let transparent = TRANSPARENT.premultiply().to_rgba8();
+
+ // Process pixels in the correct order to avoid overwriting source data.
+ // Key insight: iterate away from the direction of movement.
+ // This allows us to move pixels in-place without a temporary buffer.
+ //
+ // Use match to eliminate Box<dyn Iterator> allocation overhead and enable
+ // better compiler optimization through static dispatch.
+ match (dx_pixels >= 0, dy_pixels >= 0) {
+ (true, true) => {
+ // Shift right+down: iterate bottom-to-top, right-to-left
+ for y in (0..height).rev() {
+ for x in (0..width).rev() {
+ process_offset_pixel(
+ pixmap,
+ x,
+ y,
+ dx_pixels,
+ dy_pixels,
+ width,
+ height,
+ transparent,
+ );
+ }
+ }
+ }
+ (true, false) => {
+ // Shift right+up: iterate top-to-bottom, right-to-left
+ for y in 0..height {
+ for x in (0..width).rev() {
+ process_offset_pixel(
+ pixmap,
+ x,
+ y,
+ dx_pixels,
+ dy_pixels,
+ width,
+ height,
+ transparent,
+ );
+ }
+ }
+ }
+ (false, true) => {
+ // Shift left+down: iterate bottom-to-top, left-to-right
+ for y in (0..height).rev() {
+ for x in 0..width {
+ process_offset_pixel(
+ pixmap,
+ x,
+ y,
+ dx_pixels,
+ dy_pixels,
+ width,
+ height,
+ transparent,
+ );
+ }
+ }
+ }
+ (false, false) => {
+ // Shift left+up: iterate top-to-bottom, left-to-right
+ for y in 0..height {
+ for x in 0..width {
+ process_offset_pixel(
+ pixmap,
+ x,
+ y,
+ dx_pixels,
+ dy_pixels,
+ width,
+ height,
+ transparent,
+ );
+ }
+ }
+ }
+ }
+}
+
+/// Process a single pixel during offset operation.
+///
+/// This moves the pixel to its new position (if in bounds) and clears the source
+/// position if it's in the exposed region.
+#[inline(always)]
+fn process_offset_pixel(
+ pixmap: &mut Pixmap,
+ x: u16,
+ y: u16,
+ dx_pixels: i32,
+ dy_pixels: i32,
+ width: u16,
+ height: u16,
+ transparent: PremulRgba8,
+) {
+ let new_x = x as i32 + dx_pixels;
+ let new_y = y as i32 + dy_pixels;
+
+ if new_x >= 0 && new_x < width as i32 && new_y >= 0 && new_y < height as i32 {
+ let pixel = pixmap.sample(x, y);
+ pixmap.set_pixel(new_x as u16, new_y as u16, pixel);
+ }
+
+ // Clear the source pixel if it's in the exposed region
+ let should_clear = (dx_pixels > 0 && x < dx_pixels as u16)
+ || (dx_pixels < 0 && x >= (width as i32 + dx_pixels) as u16)
+ || (dy_pixels > 0 && y < dy_pixels as u16)
+ || (dy_pixels < 0 && y >= (height as i32 + dy_pixels) as u16);
+
+ if should_clear {
+ pixmap.set_pixel(x, y, transparent);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Test `offset_pixels` with positive offset (right and down).
+ #[test]
+ fn test_offset_pixels_positive() {
+ let mut pixmap = Pixmap::new(4, 4);
+ // Set center pixel to white
+ pixmap.set_pixel(
+ 1,
+ 1,
+ PremulRgba8 {
+ r: 255,
+ g: 255,
+ b: 255,
+ a: 255,
+ },
+ );
+
+ offset_pixels(&mut pixmap, 1.0, 1.0);
+
+ // White pixel should have moved from (1,1) to (2,2)
+ let moved = pixmap.sample(2, 2);
+ assert_eq!(moved.a, 255);
+
+ // Original position should be cleared (in exposed region)
+ let cleared = pixmap.sample(1, 1);
+ assert_eq!(cleared.a, 0);
+ }
+
+ /// Test `offset_pixels` with negative offset (left and up).
+ #[test]
+ fn test_offset_pixels_negative() {
+ let mut pixmap = Pixmap::new(4, 4);
+ // Set pixel at (2,2) to white
+ pixmap.set_pixel(
+ 2,
+ 2,
+ PremulRgba8 {
+ r: 255,
+ g: 255,
+ b: 255,
+ a: 255,
+ },
+ );
+
+ offset_pixels(&mut pixmap, -1.0, -1.0);
+
+ // White pixel should have moved from (2,2) to (1,1)
+ let moved = pixmap.sample(1, 1);
+ assert_eq!(moved.a, 255);
+
+ // Original position should be cleared (in exposed region)
+ let cleared = pixmap.sample(2, 2);
+ assert_eq!(cleared.a, 0);
+ }
+
+ /// Test `offset_pixels` with fractional offset (should round).
+ #[test]
+ fn test_offset_pixels_fractional() {
+ let mut pixmap = Pixmap::new(4, 4);
+ pixmap.set_pixel(
+ 1,
+ 1,
+ PremulRgba8 {
+ r: 255,
+ g: 255,
+ b: 255,
+ a: 255,
+ },
+ );
+
+ // 0.6 rounds to 1, -0.4 rounds to 0
+ offset_pixels(&mut pixmap, 0.6, -0.4);
+
+ // Should move by (1, 0): from (1,1) to (2,1)
+ let moved = pixmap.sample(2, 1);
+ assert_eq!(moved.a, 255);
+ }
+
+ /// Test `offset_pixels` with out-of-bounds offset (should clip).
+ #[test]
+ fn test_offset_pixels_out_of_bounds() {
+ let mut pixmap = Pixmap::new(4, 4);
+ pixmap.set_pixel(
+ 1,
+ 1,
+ PremulRgba8 {
+ r: 255,
+ g: 255,
+ b: 255,
+ a: 255,
+ },
+ );
+
+ // Large offset that moves pixel outside bounds
+ offset_pixels(&mut pixmap, 10.0, 10.0);
+
+ // Pixel moves outside, so (1,1) should be cleared
+ let cleared = pixmap.sample(1, 1);
+ assert_eq!(cleared.a, 0);
+
+ // All pixels should be cleared (entire image is exposed region)
+ for y in 0..4 {
+ for x in 0..4 {
+ assert_eq!(pixmap.sample(x, y).a, 0);
+ }
+ }
+ }
+}
diff --git a/sparse_strips/vello_sparse_tests/snapshots/filter_offset.png b/sparse_strips/vello_sparse_tests/snapshots/filter_offset.png
new file mode 100644
index 0000000..9f88ade
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/filter_offset.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:49b0b4e2fd9a31b9244258197538b5179ba6dba9e6b10bfc69e7cb5e87703c2b
+size 2385
diff --git a/sparse_strips/vello_sparse_tests/tests/filter.rs b/sparse_strips/vello_sparse_tests/tests/filter.rs
index 5c3776b..06764ac 100644
--- a/sparse_strips/vello_sparse_tests/tests/filter.rs
+++ b/sparse_strips/vello_sparse_tests/tests/filter.rs
@@ -7,10 +7,10 @@
use crate::{renderer::Renderer, util::layout_glyphs_roboto};
use vello_common::color::AlphaColor;
use vello_common::color::palette::css::{
- PURPLE, REBECCA_PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET, WHITE,
+ BLACK, PURPLE, REBECCA_PURPLE, ROYAL_BLUE, SEA_GREEN, TOMATO, VIOLET, WHITE,
};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
-use vello_common::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape};
+use vello_common::kurbo::{Affine, BezPath, Circle, Point, Rect, Shape, Stroke};
use vello_common::mask::Mask;
use vello_common::peniko::{BlendMode, Compose, Mix};
use vello_common::pixmap::Pixmap;
@@ -884,6 +884,43 @@
ctx.pop_layer();
}
+/// Test offset filter primitive.
+///
+/// This shifts content within a filter layer and should not clip content to the original bounds.
+#[vello_test(skip_hybrid, skip_multithreaded)]
+fn filter_offset(ctx: &mut impl Renderer) {
+ let filter = Filter::from_primitive(FilterPrimitive::Offset {
+ dx: 18.0,
+ dy: -12.0,
+ });
+ let star_path = circular_star(Point::new(50.0, 50.0), 7, 10.0, 22.0);
+
+ // Draw the unfiltered star as an outline at the original position.
+ ctx.set_paint(ROYAL_BLUE);
+ ctx.set_stroke(Stroke::new(1.5));
+ ctx.stroke_path(&star_path);
+
+ // Draw a marker rect at a known coordinate.
+ //
+ // This avoids trying to reason about pixel movement from an anti-aliased stroke edge.
+ let marker = Rect::new(49.0, 27.0, 53.0, 31.0);
+ ctx.set_paint(SEA_GREEN);
+ ctx.fill_rect(&marker);
+
+ // Draw the filtered (shifted) star as a fill and stroke, then draw the marker through the
+ // filter layer as well.
+ ctx.push_filter_layer(filter);
+ ctx.set_paint(TOMATO);
+ ctx.fill_path(&star_path);
+ ctx.set_paint(BLACK);
+ ctx.set_stroke(Stroke::new(1.5));
+ ctx.stroke_path(&star_path);
+ // With (dx, dy) = (18, -12) this should land at (67, 15).
+ ctx.set_paint(VIOLET);
+ ctx.fill_rect(&marker);
+ ctx.pop_layer();
+}
+
/// Test blur with various transforms (translate, rotate, scale, skew).
#[vello_test(skip_hybrid, skip_multithreaded)]
fn filter_transformed_blur(ctx: &mut impl Renderer) {