Align image rasterization more with Skia implementation (#1029)

On the one hand, to make it more SIMD-compatible, but on the other hand
for some nice performance improvements already!

```
Before:
fine/image/transform/none_u8: 893.79 ns
fine/image/transform/none_f32: 901.79 ns
fine/image/extend/pad_u8: 902.01 ns
fine/image/extend/pad_f32: 947.86 ns
fine/image/extend/repeat_u8: 2.2883 µs
fine/image/extend/repeat_f32: 2.3449 µs
fine/image/extend/reflect_u8: 2.3390 µs
fine/image/extend/reflect_f32: 2.4345 µs

After:
fine/image/transform/none_u8_scalar: 438.70 ns
fine/image/transform/none_f32_scalar: 626.66 ns
fine/image/extend/pad_u8_scalar: 464.97 ns
fine/image/extend/pad_f32_scalar: 801.11 ns
fine/image/extend/repeat_u8_scalar: 500.76 ns
fine/image/extend/repeat_f32_scalar: 682.00 ns
fine/image/extend/reflect_u8_scalar: 876.11 ns
fine/image/extend/reflect_f32_scalar: 1.1661 µs
```
diff --git a/sparse_strips/vello_bench/src/fine/image.rs b/sparse_strips/vello_bench/src/fine/image.rs
index 74b99d9..21fcca1 100644
--- a/sparse_strips/vello_bench/src/fine/image.rs
+++ b/sparse_strips/vello_bench/src/fine/image.rs
@@ -13,6 +13,7 @@
 use vello_common::pixmap::Pixmap;
 use vello_cpu::fine::{Fine, FineType};
 
+// TODO: Add benchmarks for images with transparency
 pub fn image(c: &mut Criterion) {
     transform::none(c);
     transform::scale(c);
diff --git a/sparse_strips/vello_common/src/encode.rs b/sparse_strips/vello_common/src/encode.rs
index fd6ab7b..f842b0c 100644
--- a/sparse_strips/vello_common/src/encode.rs
+++ b/sparse_strips/vello_common/src/encode.rs
@@ -438,7 +438,9 @@
     fn encode_into(&self, paints: &mut Vec<EncodedPaint>, transform: Affine) -> Paint {
         let idx = paints.len();
 
-        let transform = transform.inverse();
+        // Similarly to gradients, apply a 0.5 offset so we sample at the center of
+        // a pixel.
+        let transform = transform.inverse() * Affine::translate((0.5, 0.5));
         // TODO: This is somewhat expensive for large images, maybe it's not worth optimizing
         // non-opaque images in the first place..
         let has_opacities = self.pixmap.data().iter().any(|pixel| pixel.a != 255);
diff --git a/sparse_strips/vello_cpu/src/fine/image.rs b/sparse_strips/vello_cpu/src/fine/image.rs
index a455d5e..0cd3964 100644
--- a/sparse_strips/vello_cpu/src/fine/image.rs
+++ b/sparse_strips/vello_cpu/src/fine/image.rs
@@ -11,39 +11,43 @@
 use vello_common::kurbo::common::FloatFuncs as _;
 
 #[cfg(feature = "std")]
-fn fract(val: f64) -> f64 {
-    val.fract()
+fn floor(val: f32) -> f32 {
+    val.floor()
 }
 
 #[cfg(not(feature = "std"))]
-fn fract(val: f64) -> f64 {
+fn floor(val: f32) -> f32 {
     #[cfg(feature = "libm")]
-    return val - libm::trunc(val);
+    return libm::floorf(val);
     #[cfg(not(feature = "libm"))]
     compile_error!("vello_common requires either the `std` or `libm` feature");
 }
 
-// Inlined version of f32::rem_euclid, to allow using abs.
-fn rem_euclid(lhs: f32, rhs: f32) -> f32 {
-    let r = lhs % rhs;
-    if r < 0.0 { r + rhs.abs() } else { r }
-}
-
 #[derive(Debug)]
 pub(crate) struct ImageFiller<'a> {
     /// The current position that should be processed.
     cur_pos: Point,
     /// The underlying image.
     image: &'a EncodedImage,
+    // Precomputed values reused in per-pixel calculations.
+    height: f32,
+    height_inv: f32,
+    width: f32,
+    width_inv: f32,
 }
 
 impl<'a> ImageFiller<'a> {
     pub(crate) fn new(image: &'a EncodedImage, start_x: u16, start_y: u16) -> Self {
+        let width = image.pixmap.width() as f32;
+        let height = image.pixmap.height() as f32;
+
         Self {
-            // We want to sample values of the pixels at the center, so add an offset of 0.5.
-            cur_pos: image.transform
-                * Point::new(f64::from(start_x) + 0.5, f64::from(start_y) + 0.5),
+            cur_pos: image.transform * Point::new(f64::from(start_x), f64::from(start_y)),
             image,
+            width,
+            height,
+            width_inv: 1.0 / width,
+            height_inv: 1.0 / height,
         }
     }
 
@@ -78,11 +82,10 @@
 
             for (idx, pos) in y_positions.iter_mut().enumerate() {
                 *pos = extend(
-                    // Since we already added a 0.5 offset to sample at the center of the pixel,
-                    // we always floor to get the target pixel.
-                    (self.cur_pos.y + y_advance * idx as f64).floor() as f32,
+                    (self.cur_pos.y + y_advance * idx as f64) as f32,
                     self.image.extends.1,
-                    f32::from(self.image.pixmap.height()),
+                    self.height,
+                    self.height_inv,
                 );
             }
 
@@ -90,10 +93,10 @@
                 .chunks_exact_mut(TILE_HEIGHT_COMPONENTS)
                 .for_each(|column| {
                     let extended_x_pos = extend(
-                        // As above, always floor.
-                        x_pos.floor() as f32,
+                        x_pos as f32,
                         self.image.extends.0,
-                        f32::from(self.image.pixmap.width()),
+                        self.width,
+                        self.width_inv,
                     );
                     self.run_simple_column(column, extended_x_pos, &y_positions);
                     x_pos += x_advance;
@@ -133,16 +136,17 @@
 
     fn run_complex_column<F: FineType>(&mut self, col: &mut [F]) {
         let extend_point = |mut point: Point| {
-            // For the same reason as mentioned above, we always floor.
             point.x = f64::from(extend(
-                point.x.floor() as f32,
+                point.x as f32,
                 self.image.extends.0,
-                f32::from(self.image.pixmap.width()),
+                self.width,
+                self.width_inv,
             ));
             point.y = f64::from(extend(
-                point.y.floor() as f32,
+                point.y as f32,
                 self.image.extends.1,
-                f32::from(self.image.pixmap.height()),
+                self.height,
+                self.height_inv,
             ));
 
             point
@@ -176,32 +180,12 @@
                     // center of the location we are sampling, and sample those points
                     // using a cubic filter to weight each location's contribution.
 
-                    let fract = |orig_val: f64| {
-                        // To give some intuition on why we need that shift, based on bilinear
-                        // filtering: If we sample at the position (0.5, 0.5), we are at the center
-                        // of the pixel and thus only want the color of the current pixel. Thus, we take
-                        // 1.0 * 1.0 from the top left pixel (which still lies on our pixel)
-                        // and 0.0 from all other corners (which lie at the start of other pixels).
-                        //
-                        // If we sample at the position (0.4, 0.4), we want 0.1 * 0.1 = 0.01 from
-                        // the top-left pixel, 0.1 * 0.9 = 0.09 from the bottom-left and top-right,
-                        // and finally 0.9 * 0.9 = 0.81 from the bottom right position (which still
-                        // lies on our pixel, and thus has intuitively should have the highest
-                        // contribution). Thus, we need to subtract 0.5 from the position to get
-                        // the correct fractional contribution.
-                        let start = orig_val - 0.5;
-                        let mut res = fract(start) as f32;
+                    fn fract(val: f32) -> f32 {
+                        val - floor(val)
+                    }
 
-                        // In case we are in the negative we need to mirror the result.
-                        if res.is_sign_negative() {
-                            res += 1.0;
-                        }
-
-                        res
-                    };
-
-                    let x_fract = fract(pos.x);
-                    let y_fract = fract(pos.y);
+                    let x_fract = fract(pos.x as f32 + 0.5);
+                    let y_fract = fract(pos.y as f32 + 0.5);
 
                     let mut interpolated_color = [0.0_f32; 4];
 
@@ -213,6 +197,7 @@
                     };
 
                     if self.image.quality == ImageQuality::Medium {
+                        // <https://github.com/google/skia/blob/220738774f7a0ce4a6c7bd17519a336e5e5dea5b/src/opts/SkRasterPipeline_opts.h#L5039-L5078>
                         let cx = [1.0 - x_fract, x_fract];
                         let cy = [1.0 - y_fract, y_fract];
 
@@ -234,7 +219,7 @@
                             }
                         }
                     } else {
-                        // Compare to https://github.com/google/skia/blob/84ff153b0093fc83f6c77cd10b025c06a12c5604/src/opts/SkRasterPipeline_opts.h#L5030-L5075.
+                        // Compare to <https://github.com/google/skia/blob/84ff153b0093fc83f6c77cd10b025c06a12c5604/src/opts/SkRasterPipeline_opts.h#L5030-L5075>.
                         let cx = weights(x_fract);
                         let cy = weights(y_fract);
 
@@ -278,21 +263,31 @@
     }
 }
 
-fn extend(val: f32, extend: Extend, max: f32) -> f32 {
+#[inline(always)]
+fn extend(val: f32, extend: Extend, max: f32, inv_max: f32) -> f32 {
+    // We cannot chose f32::EPSILON here because for example 30.0 - f32::EPSILON is still 30.0.
+    // This bias should be large enough for all numbers that we support (i.e. <= u16::MAX).
+    const BIAS: f32 = 0.01;
+
     match extend {
-        Extend::Pad => val.clamp(0.0, max - 1.0),
-        // TODO: We need to make repeat and reflect more efficient and branch-less.
-        Extend::Repeat => rem_euclid(val, max),
+        // Note that max should be exclusive, so subtract a small bias to enforce that.
+        // Otherwise, we might sample out-of-bounds pixels.
+        Extend::Pad => val.clamp(0.0, max - BIAS),
+        Extend::Repeat => val - floor(val * inv_max) * max,
+        // <https://github.com/google/skia/blob/220738774f7a0ce4a6c7bd17519a336e5e5dea5b/src/opts/SkRasterPipeline_opts.h#L3274-L3290>
         Extend::Reflect => {
-            let period = 2.0 * max;
+            let u = val - floor(val * inv_max * 0.5) * 2.0 * max;
+            let s = floor(u * inv_max);
+            let m = u - 2.0 * s * (u - max);
 
-            let val_mod = rem_euclid(val, period);
+            let bias_in_ulps = s.trunc();
 
-            if val_mod < max {
-                val_mod
-            } else {
-                (period - 1.0) - val_mod
-            }
+            let m_bits = m.to_bits();
+            // This would yield NaN if `m` is 0 and `bias_in_ulps` > 0, but since
+            // our `max` is always an integer number, u and s must also be an integer number
+            // and thus `m_bits` must be 0.
+            let biased_bits = m_bits.wrapping_sub(bias_in_ulps as u32);
+            f32::from_bits(biased_bits)
         }
     }
 }
diff --git a/sparse_strips/vello_sparse_tests/snapshots/glyphs_bitmap_apple.png b/sparse_strips/vello_sparse_tests/snapshots/glyphs_bitmap_apple.png
index 574d518..3346eba 100644
--- a/sparse_strips/vello_sparse_tests/snapshots/glyphs_bitmap_apple.png
+++ b/sparse_strips/vello_sparse_tests/snapshots/glyphs_bitmap_apple.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:99e38374f05358bef1fbd667d6804aec2cae31770d8597a027aaf75a444fcce5
-size 13994
+oid sha256:88b2d132ac8dfee79861b7078bef9678fcfd36b5cdb338e4b1342acfd73f78d7
+size 14012
diff --git a/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_rotation.png b/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_rotation.png
index dcb9c1b..05ec975 100644
--- a/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_rotation.png
+++ b/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_rotation.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:f64982ff9b2b4668f4f11b851b7dd8f11d1ab994604cb90abe6620cb35083df5
-size 12719
+oid sha256:1fa5377b73650912f2653208cd49340705bc9a1b522915e7f0c4c44533052d06
+size 12716
diff --git a/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_translation.png b/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_translation.png
index dbddf1c..006c508 100644
--- a/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_translation.png
+++ b/sparse_strips/vello_sparse_tests/snapshots/image_bicubic_with_translation.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:1911f5291e5f90a032c50f7a7a4f687216b88aa65793dc12c170b8b7ab6be626
-size 648
+oid sha256:52e9abf295f3a8e083c685dc8a41f4f39623f6b8b69b9a1c5f6ba3ed9287032c
+size 644