Add early culling for lines fully left of the viewport. (#1368)

Apologies this took an embarrassingly long time due to a funny bug. Adds
changes as discussed.

UPDATED:
Lines left of the viewport are not visible but still contribute winding,
creating a left to right dependency for rendering. This patch simplifies
rendering in this scenario by culling the left-of-viewport portion of
lines, and recording their equivalent winding contribution inside a
histogram. If a line is *entirely* left of the viewport, it is
completely culled and produces no tiles.

Correspondingly `strip.rs` has been updated to consume this histogram.
Notably, `strip.rs` now handles cases where no tiles are present, but
culling occured, and cases where winding is "captive" either between
left edge of the viewport and the first tile in the row, or between two
non-consecutive rows.

Removes the previous handling of left of edge lines in `strip.rs`.

No culling, Ghostscript_Tiger shifted 50% off screen:
```
tile_aaa_shift50/Ghostscript_Tiger
                        time:   [51.574 µs 51.650 µs 51.732 µs]
```

With culling:
```
tile_aaa_shift50_cull/Ghostscript_Tiger
                        time:   [46.319 µs 46.380 µs 46.436 µs]
```

---------

Co-authored-by: Thomas Smith <thomsmit@google.com>
Co-authored-by: Laurenz Stampfl <laurenz.stampfl+github@gmail.com>
diff --git a/sparse_strips/vello_bench/benches/main.rs b/sparse_strips/vello_bench/benches/main.rs
index 188922b..8e1b563 100644
--- a/sparse_strips/vello_bench/benches/main.rs
+++ b/sparse_strips/vello_bench/benches/main.rs
@@ -22,6 +22,7 @@
 criterion_group!(flatten, flatten::flatten);
 criterion_group!(strokes, flatten::strokes);
 criterion_group!(render_strips, strip::render_strips);
+criterion_group!(render_strips_cull, strip::render_strips_cull);
 criterion_group!(render_rect, strip::render_rect);
 criterion_group!(glyph, glyph::glyph);
 criterion_group!(sort_tiles, sort::sort);
@@ -33,6 +34,7 @@
     coarse_layer_4k,
     tile,
     render_strips,
+    render_strips_cull,
     render_rect,
     flatten,
     strokes,
diff --git a/sparse_strips/vello_bench/src/data.rs b/sparse_strips/vello_bench/src/data.rs
index a192872..eda3b7a 100644
--- a/sparse_strips/vello_bench/src/data.rs
+++ b/sparse_strips/vello_bench/src/data.rs
@@ -132,9 +132,9 @@
 
     /// Get the unsorted tiles.
     pub fn unsorted_tiles(&self) -> Tiles {
-        let mut tiles = Tiles::new(Level::new());
+        let mut tiles = Tiles::new(Level::new(), self.height);
         let lines = self.lines();
-        tiles.make_tiles_analytic_aa(&lines, self.width, self.height);
+        tiles.make_tiles_analytic_aa(Level::new(), &lines, self.width, self.height);
 
         tiles
     }
diff --git a/sparse_strips/vello_bench/src/strip.rs b/sparse_strips/vello_bench/src/strip.rs
index bfa5799..0ec6cbf 100644
--- a/sparse_strips/vello_bench/src/strip.rs
+++ b/sparse_strips/vello_bench/src/strip.rs
@@ -4,9 +4,33 @@
 use crate::data::get_data_items;
 use criterion::Criterion;
 use vello_common::fearless_simd::Level;
+use vello_common::flatten::Line;
 use vello_common::kurbo::{Affine, Rect, Shape};
 use vello_common::peniko::Fill;
 use vello_common::strip_generator::{StripGenerator, StripStorage};
+use vello_common::tile::Tiles;
+
+pub fn shift_lines_50_percent(lines: &[Line]) -> Vec<Line> {
+    if lines.is_empty() {
+        return vec![];
+    }
+
+    let mut min_x = f32::MAX;
+    let mut max_x = f32::MIN;
+    for line in lines {
+        min_x = min_x.min(line.p0.x).min(line.p1.x);
+        max_x = max_x.max(line.p0.x).max(line.p1.x);
+    }
+
+    let shift_amount = (min_x + max_x) / 2.0;
+
+    let mut shifted = lines.to_vec();
+    for line in &mut shifted {
+        line.p0.x -= shift_amount;
+        line.p1.x -= shift_amount;
+    }
+    shifted
+}
 
 pub fn render_strips(c: &mut Criterion) {
     let mut g = c.benchmark_group("render_strips");
@@ -48,6 +72,47 @@
             strip_single!(item, simd_level, "simd");
         }
     }
+    g.finish();
+}
+
+pub fn render_strips_cull(c: &mut Criterion) {
+    let mut g_cull = c.benchmark_group("render_strips_culled50");
+    g_cull.sample_size(50);
+
+    for item in get_data_items() {
+        let simd_level = Level::new();
+        if simd_level.is_fallback() {
+            continue;
+        }
+
+        let shifted_lines = shift_lines_50_percent(&item.lines());
+
+        let mut tiler = Tiles::new(simd_level, item.height);
+        tiler.make_tiles_analytic_aa(simd_level, &shifted_lines, item.width, item.height);
+        tiler.sort_tiles();
+
+        g_cull.bench_function(item.name.clone().to_string(), |b| {
+            let mut strip_buf = vec![];
+            let mut alpha_buf = vec![];
+
+            b.iter(|| {
+                strip_buf.clear();
+                alpha_buf.clear();
+
+                vello_common::strip::render(
+                    simd_level,
+                    &tiler,
+                    &mut strip_buf,
+                    &mut alpha_buf,
+                    Fill::NonZero,
+                    None,
+                    &shifted_lines,
+                );
+                std::hint::black_box((&strip_buf, &alpha_buf));
+            });
+        });
+    }
+    g_cull.finish();
 }
 
 pub fn render_rect(c: &mut Criterion) {
diff --git a/sparse_strips/vello_bench/src/tile.rs b/sparse_strips/vello_bench/src/tile.rs
index 2dcba2c..f685329 100644
--- a/sparse_strips/vello_bench/src/tile.rs
+++ b/sparse_strips/vello_bench/src/tile.rs
@@ -2,23 +2,29 @@
 // SPDX-License-Identifier: Apache-2.0 OR MIT
 
 use crate::data::get_data_items;
+use crate::strip::shift_lines_50_percent;
 use criterion::{BenchmarkId, Criterion};
 use vello_common::flatten::Line;
 use vello_common::tile::Tiles;
 use vello_cpu::Level;
 
-fn run_tile_benchmark<F>(c: &mut Criterion, group_name: &str, op: F)
+fn run_tile_benchmark<const SHIFT: bool, F>(c: &mut Criterion, group_name: &str, mut op: F)
 where
-    F: Fn(&mut Tiles, &[Line], u16, u16) + Copy,
+    F: FnMut(&mut Tiles, &[Line], u16, u16),
 {
     let mut g = c.benchmark_group(group_name);
     g.sample_size(50);
 
     for item in get_data_items() {
-        let lines = item.lines();
+        let lines = if SHIFT {
+            shift_lines_50_percent(&item.lines())
+        } else {
+            item.lines()
+        };
+
         g.bench_with_input(BenchmarkId::from_parameter(&item.name), &item, |b, item| {
             b.iter(|| {
-                let mut tiler = Tiles::new(Level::new());
+                let mut tiler = Tiles::new(Level::new(), item.height);
                 op(&mut tiler, &lines, item.width, item.height);
             });
         });
@@ -27,6 +33,15 @@
 }
 
 pub fn tile(c: &mut Criterion) {
-    run_tile_benchmark(c, "tile_aaa", Tiles::make_tiles_analytic_aa);
-    run_tile_benchmark(c, "tile_msaa", Tiles::make_tiles_msaa);
+    run_tile_benchmark::<false, _>(c, "tile_aaa", |tiler, lines, w, h| {
+        tiler.make_tiles_analytic_aa(Level::new(), lines, w, h);
+    });
+
+    run_tile_benchmark::<false, _>(c, "tile_msaa", |tiler, lines, w, h| {
+        tiler.make_tiles_msaa(lines, w, h);
+    });
+
+    run_tile_benchmark::<true, _>(c, "tile_aaa_shift50", |tiler, lines, w, h| {
+        tiler.make_tiles_analytic_aa(Level::new(), lines, w, h);
+    });
 }
diff --git a/sparse_strips/vello_common/src/strip.rs b/sparse_strips/vello_common/src/strip.rs
index b38529a..315f197 100644
--- a/sparse_strips/vello_common/src/strip.rs
+++ b/sparse_strips/vello_common/src/strip.rs
@@ -88,6 +88,38 @@
         self.packed_alpha_idx_fill_gap =
             (self.packed_alpha_idx_fill_gap & !Self::FILL_GAP_MASK) | fill;
     }
+
+    /// When early culling is active, geometry fully to the left of the viewport creates no tiles.
+    /// However, if that geometry has a non-zero winding (e.g. a large shape surrounding the
+    /// viewport), then we must output strips for those fills.
+    ///
+    /// We reconstruct this "background" fill using `row_windings` (the winding at x=0) to emit solid
+    /// strips for:
+    ///      1. All rows vertically above the first visible tile.
+    ///      2. 'Captive' rows between two tile-containing rows.
+    ///      3. All rows vertically below the last visible tile.
+    #[inline(always)]
+    fn emit_culled_background<F>(
+        start: u16,
+        end: u16,
+        strips: &mut Vec<Self>,
+        alphas: &mut Vec<u8>,
+        windings: &crate::tile::CulledWindings,
+        mut should_fill: F,
+    ) where
+        F: FnMut(i32) -> bool,
+    {
+        windings.for_active_rows_in_range(start as usize, end as usize, |row| {
+            if should_fill(windings.coarse[row] as i32) {
+                let y_pos = row as u16 * Tile::HEIGHT;
+                strips.push(Self::new(0, y_pos, alphas.len() as u32, false));
+                alphas.extend([255_u8; Tile::HEIGHT as usize * Tile::WIDTH as usize]);
+                // TODO: Clamp to the scene width instead of u16::MAX; in the future there might
+                // not be clamping on the x.
+                strips.push(Self::new(u16::MAX, y_pos, alphas.len() as u32, true));
+            }
+        });
+    }
 }
 
 /// Render the tiles stored in `tiles` into the strip and alpha buffer.
@@ -100,7 +132,13 @@
     aliasing_threshold: Option<u8>,
     lines: &[Line],
 ) {
-    dispatch!(level, simd => render_impl(simd, tiles, strip_buf, alpha_buf, fill_rule, aliasing_threshold, lines));
+    dispatch!(level, simd => render_impl(simd,
+                                         tiles,
+                                         strip_buf,
+                                         alpha_buf,
+                                         fill_rule,
+                                         aliasing_threshold,
+                                         lines));
 }
 
 #[inline(always)]
@@ -113,7 +151,13 @@
     aliasing_threshold: Option<u8>,
     lines: &[Line],
 ) {
-    if tiles.is_empty() {
+    let row_windings = &tiles.windings.coarse;
+    let has_culled_tiles = tiles.has_culled_tiles();
+
+    // If no tiles were culled and the tile buffer is empty, we can simply exit. If tiles were
+    // culled, the tile buffer may be empty but there may be winding produced by culled geometry
+    // left of the viewport that must be checked for filling.
+    if !has_culled_tiles && tiles.is_empty() {
         return;
     }
 
@@ -122,13 +166,39 @@
         Fill::EvenOdd => winding % 2 != 0,
     };
 
+    // Helper to handle "captive strips". When a row has tiles, but the first tile
+    // is not at the left edge of the viewport (x != 0), we must emit a solid strip
+    // from x=0 to that tile if the coarse winding dictates a fill.
+    let emit_captive_strip =
+        |y: u16, is_left_viewport: bool, strips: &mut Vec<Strip>, alphas: &mut Vec<u8>| {
+            let coarse_wd = tiles.windings.coarse[y as usize] as i32;
+
+            if should_fill(coarse_wd) && !is_left_viewport {
+                strips.push(Strip::new(0, y * Tile::HEIGHT, alphas.len() as u32, false));
+                alphas.extend([255_u8; Tile::HEIGHT as usize * Tile::WIDTH as usize]);
+            }
+
+            let mut acc = f32x4::splat(s, coarse_wd as f32);
+            if is_left_viewport {
+                let fine_winding: f32x4<_> = tiles.windings.partial[y as usize].simd_into(s);
+                acc += fine_winding;
+            }
+
+            (coarse_wd, acc)
+        };
+
     // The accumulated tile winding delta. A line that crosses the top edge of a tile
     // increments the delta if the line is directed upwards, and decrements it if goes
     // downwards. Horizontal lines leave it unchanged.
     let mut winding_delta: i32 = 0;
 
     // The previous tile visited.
-    let mut prev_tile = *tiles.get(0);
+    let mut prev_tile = if has_culled_tiles && tiles.is_empty() {
+        Tile::SENTINEL
+    } else {
+        *tiles.get(0)
+    };
+
     // The accumulated (fractional) winding of the tile-sized location we're currently at.
     // Note multiple tiles can be at the same location.
     // Note that we are also implicitly assuming here that the tile height exactly fits into a
@@ -138,18 +208,35 @@
     // next location, this is splatted to that location's starting winding.
     let mut accumulated_winding = f32x4::splat(s, 0.0);
 
-    /// A special tile to keep the logic below simple.
-    const SENTINEL: Tile = Tile::new(u16::MAX, u16::MAX, 0, 0);
+    let left_viewport = prev_tile.x == 0;
+    if has_culled_tiles {
+        let row_max = prev_tile.y.min(row_windings.len() as u16);
+        Strip::emit_culled_background(
+            0,
+            row_max,
+            strip_buf,
+            alpha_buf,
+            &tiles.windings,
+            should_fill,
+        );
+        if tiles.is_empty() {
+            return;
+        }
+        let (wd, acc) = emit_captive_strip(prev_tile.y, left_viewport, strip_buf, alpha_buf);
+        winding_delta = wd;
+        accumulated_winding = acc;
+        location_winding = [accumulated_winding; Tile::WIDTH as usize];
+    }
 
     // The strip we're building.
     let mut strip = Strip::new(
         prev_tile.x * Tile::WIDTH,
         prev_tile.y * Tile::HEIGHT,
         alpha_buf.len() as u32,
-        false,
+        should_fill(winding_delta) && !left_viewport,
     );
 
-    for (tile_idx, tile) in tiles.iter().copied().chain([SENTINEL]).enumerate() {
+    for (tile_idx, tile) in tiles.iter().copied().chain([Tile::SENTINEL]).enumerate() {
         let line = lines[tile.line_idx() as usize];
         let tile_left_x = f32::from(tile.x) * f32::from(Tile::WIDTH);
         let tile_top_y = f32::from(tile.y) * f32::from(Tile::HEIGHT);
@@ -227,6 +314,7 @@
             strip_buf.push(strip);
 
             let is_sentinel = tile_idx == tiles.len() as usize;
+            let left_viewport = tile.x == 0;
             if !prev_tile.same_row(&tile) {
                 // Emit a final strip in the row if there is non-zero winding for the sparse fill,
                 // or unconditionally if we've reached the sentinel tile to end the path (the
@@ -240,13 +328,34 @@
                     ));
                 }
 
-                winding_delta = 0;
-                accumulated_winding = f32x4::splat(s, 0.0);
+                // Logic identical to the start (see above): fill any vertical gaps (empty rows)
+                // between the previous and current tile using the row windings.
+                if has_culled_tiles && !is_sentinel {
+                    Strip::emit_culled_background(
+                        prev_tile.y + 1,
+                        tile.y,
+                        strip_buf,
+                        alpha_buf,
+                        &tiles.windings,
+                        should_fill,
+                    );
+
+                    let (wd, acc) = emit_captive_strip(tile.y, left_viewport, strip_buf, alpha_buf);
+                    winding_delta = wd;
+                    accumulated_winding = acc;
+                } else {
+                    winding_delta = 0;
+                    accumulated_winding = f32x4::splat(s, 0.0);
+                };
 
                 #[expect(clippy::needless_range_loop, reason = "dimension clarity")]
                 for x in 0..Tile::WIDTH as usize {
                     location_winding[x] = accumulated_winding;
                 }
+            } else {
+                // Note: this fill is mathematically not necessary. It provides a way to reduce
+                // accumulation of float rounding errors.
+                accumulated_winding = f32x4::splat(s, winding_delta as f32);
             }
 
             if is_sentinel {
@@ -257,11 +366,8 @@
                 tile.x * Tile::WIDTH,
                 tile.y * Tile::HEIGHT,
                 alpha_buf.len() as u32,
-                should_fill(winding_delta),
+                should_fill(winding_delta) && !left_viewport,
             );
-            // Note: this fill is mathematically not necessary. It provides a way to reduce
-            // accumulation of float rounding errors.
-            accumulated_winding = f32x4::splat(s, winding_delta as f32);
         }
         prev_tile = tile;
 
@@ -311,54 +417,11 @@
             (p1_y, p1_x, p0_y, p0_x)
         };
 
-        let (line_left_x, line_left_y, line_right_x) = if p0_x < p1_x {
-            (p0_x, p0_y, p1_x)
-        } else {
-            (p1_x, p1_y, p0_x)
-        };
-
         let y_slope = (line_bottom_y - line_top_y) / (line_bottom_x - line_top_x);
         let x_slope = 1. / y_slope;
 
         winding_delta += sign as i32 * i32::from(tile.winding());
 
-        // TODO: this should be removed when out-of-viewport tiles are culled at the
-        // tile-generation stage. That requires calculating and forwarding winding to strip
-        // generation.
-        if tile.x == 0 && line_left_x < 0. {
-            let (ymin, ymax) = if line.p0.x == line.p1.x {
-                (line_top_y, line_bottom_y)
-            } else {
-                let line_viewport_left_y = (line_top_y - line_top_x * y_slope)
-                    .max(line_top_y)
-                    .min(line_bottom_y);
-
-                (
-                    f32::min(line_left_y, line_viewport_left_y),
-                    f32::max(line_left_y, line_viewport_left_y),
-                )
-            };
-
-            let ymin: f32x4<_> = ymin.simd_into(s);
-            let ymax: f32x4<_> = ymax.simd_into(s);
-
-            let px_top_y: f32x4<_> = [0.0, 1.0, 2.0, 3.0].simd_into(s);
-            let px_bottom_y = 1.0 + px_top_y;
-            let ymin = px_top_y.max(ymin);
-            let ymax = px_bottom_y.min(ymax);
-            let h = (ymax - ymin).max(0.0);
-            accumulated_winding = h.mul_add(sign, accumulated_winding);
-            for x_idx in 0..Tile::WIDTH {
-                location_winding[x_idx as usize] =
-                    h.mul_add(sign, location_winding[x_idx as usize]);
-            }
-
-            if line_right_x < 0. {
-                // Early exit, as no part of the line is inside the tile.
-                continue;
-            }
-        }
-
         let line_top_y = f32x4::splat(s, line_top_y);
         let line_bottom_y = f32x4::splat(s, line_bottom_y);
 
@@ -458,4 +521,15 @@
 
         accumulated_winding += acc;
     }
+
+    if has_culled_tiles {
+        Strip::emit_culled_background(
+            (prev_tile.y + 1).min(row_windings.len() as u16),
+            row_windings.len() as u16,
+            strip_buf,
+            alpha_buf,
+            &tiles.windings,
+            should_fill,
+        );
+    }
 }
diff --git a/sparse_strips/vello_common/src/strip_generator.rs b/sparse_strips/vello_common/src/strip_generator.rs
index fde085a..9a8bfc2 100644
--- a/sparse_strips/vello_common/src/strip_generator.rs
+++ b/sparse_strips/vello_common/src/strip_generator.rs
@@ -95,7 +95,7 @@
         Self {
             level,
             line_buf: Vec::new(),
-            tiles: Tiles::new(level),
+            tiles: Tiles::new(level, height),
             flatten_ctx: FlattenCtx::default(),
             stroke_ctx: StrokeCtx::default(),
             temp_storage: StripStorage::default(),
@@ -175,7 +175,8 @@
         clip_path: Option<PathDataRef<'_>>,
     ) {
         self.tiles
-            .make_tiles_analytic_aa(&self.line_buf, self.width, self.height);
+            .make_tiles_analytic_aa(self.level, &self.line_buf, self.width, self.height);
+
         self.tiles.sort_tiles();
 
         let level = self.level;
diff --git a/sparse_strips/vello_common/src/tile.rs b/sparse_strips/vello_common/src/tile.rs
index f980fa9..f23433d 100644
--- a/sparse_strips/vello_common/src/tile.rs
+++ b/sparse_strips/vello_common/src/tile.rs
@@ -6,7 +6,7 @@
 use crate::flatten::Line;
 use alloc::vec;
 use alloc::vec::Vec;
-use fearless_simd::Level;
+use fearless_simd::*;
 #[cfg(not(feature = "std"))]
 use peniko::kurbo::common::FloatFuncs as _;
 
@@ -40,6 +40,159 @@
 /// Trying to render a path with more lines than this may result in visual artifacts.
 pub const MAX_LINES_PER_PATH: u32 = 1 << (32 - INT_MASK_SHIFT);
 
+/// A logical grouping of arrays used for culled tile processing,
+#[derive(Debug, Clone, Default)]
+pub struct CulledWindings {
+    /// Fractional winding coverage for each individual scanline in a row.
+    pub partial: Vec<[f32; Tile::HEIGHT as usize]>,
+    // Note that this will cause issues if we have windings greater/less than i8,
+    // but this should only occur in pathological cases.
+    /// Accumulated integer winding deltas for each tile row.
+    pub coarse: Vec<i8>,
+    /// Bitmask tracking which rows contain active geometry or winding data.
+    pub active: Vec<u32>,
+    /// Flag indicating if any geometry was early-culled outside the viewport.
+    pub culled: bool,
+}
+
+impl CulledWindings {
+    /// Number of bits in a single active mask word.
+    const WORD_BITS: usize = 32;
+    /// Bit shift equivalent to dividing by `WORD_BITS` (2^5 = 32).
+    const WORD_SHIFT: usize = 5;
+    /// Bitmask equivalent to modulo `WORD_BITS` (32 - 1 = 31).
+    const WORD_MASK: usize = 31;
+
+    /// Constructor chained to `Tiles`' constructor and matches its lifetime. Since `Tiles` itself
+    /// matches the lifetime of `StripGenerator`, we know that the viewport dimensions will never
+    /// change, and thus the backing vecs never need to be resized. (For now).
+    pub fn new(height: u16) -> Self {
+        let height_usize = height as usize;
+        let tile_height = Tile::HEIGHT as usize;
+        let num_rows = height_usize.div_ceil(tile_height);
+        let num_bits = num_rows.div_ceil(Self::WORD_BITS);
+
+        Self {
+            partial: vec![[0.0; Tile::HEIGHT as usize]; num_rows],
+            coarse: vec![0; num_rows],
+            active: vec![0; num_bits],
+            culled: false,
+        }
+    }
+
+    /// Clears but does not resize
+    pub fn reset(&mut self) {
+        // TODO: Maybe consider tracking touched regions and only resetting those
+        // instead of always the full array?
+        if self.culled {
+            self.partial.fill([0.0; Tile::HEIGHT as usize]);
+            self.coarse.fill(0);
+            self.active.fill(0);
+            self.culled = false;
+        }
+    }
+
+    /// Marks if a row was culled early for faster traversal in strip generation.
+    #[inline(always)]
+    pub fn mark_row_active(&mut self, row_idx: usize) {
+        self.active[row_idx >> Self::WORD_SHIFT] |= 1 << (row_idx & Self::WORD_MASK);
+    }
+
+    /// Bulk marks a range of rows as active [`start_row`, `end_row`).
+    #[inline(always)]
+    pub fn mark_row_range_active(&mut self, start_row: usize, end_row: usize) {
+        if start_row >= end_row {
+            return;
+        }
+
+        let start_word = start_row >> Self::WORD_SHIFT;
+        let end_word = (end_row - 1) >> Self::WORD_SHIFT;
+
+        if start_word == end_word {
+            // All bits fall within the same u32 word
+            let shift = start_row & Self::WORD_MASK;
+            let count = end_row - start_row;
+            let mask = if count == Self::WORD_BITS {
+                u32::MAX
+            } else {
+                ((1_u32 << count) - 1) << shift
+            };
+            self.active[start_word] |= mask;
+        } else {
+            // Bits span multiple words: handle start, full middle words, and end
+            self.active[start_word] |= u32::MAX << (start_row & Self::WORD_MASK);
+
+            self.active[(start_word + 1)..end_word].fill(u32::MAX);
+
+            let end_shift = ((end_row - 1) & Self::WORD_MASK) + 1;
+            let mask = if end_shift == Self::WORD_BITS {
+                u32::MAX
+            } else {
+                (1_u32 << end_shift) - 1
+            };
+            self.active[end_word] |= mask;
+        }
+    }
+
+    /// Calls `f` on active rows in the range [`start`, `end`).
+    #[inline(always)]
+    pub fn for_active_rows_in_range<F>(&self, start: usize, end: usize, mut f: F)
+    where
+        F: FnMut(usize),
+    {
+        if start >= end {
+            return;
+        }
+
+        let start_word = start >> Self::WORD_SHIFT;
+        let end_word = (end - 1) >> Self::WORD_SHIFT;
+
+        let mut process_word = |mut word: u32, word_idx: usize| {
+            while word != 0 {
+                let bit = word.trailing_zeros();
+                word &= !(1_u32 << bit);
+                f((word_idx << Self::WORD_SHIFT) + bit as usize);
+            }
+        };
+
+        let end_limit = ((end - 1) & Self::WORD_MASK) + 1;
+        let end_mask = if end_limit == Self::WORD_BITS {
+            u32::MAX
+        } else {
+            (1_u32 << end_limit) - 1
+        };
+
+        // Start Word
+        {
+            let mut word = self.active[start_word];
+            let start_bit = start & Self::WORD_MASK;
+            word &= !((1_u32 << start_bit) - 1);
+
+            if start_word == end_word {
+                word &= end_mask;
+            }
+
+            process_word(word, start_word);
+        }
+
+        // Middle Words & End Word
+        if start_word < end_word {
+            for (word_idx, &word_val) in self
+                .active
+                .iter()
+                .enumerate()
+                .take(end_word)
+                .skip(start_word + 1)
+            {
+                process_word(word_val, word_idx);
+            }
+
+            // End Word
+            process_word(self.active[end_word] & end_mask, end_word);
+        }
+    }
+}
+
 /// A tile represents an aligned area on the pixmap, used to subdivide the viewport into sub-areas
 /// (currently 4x4) and analyze line intersections inside each such area.
 ///
@@ -100,6 +253,9 @@
     /// The height of a tile in pixels.
     pub const HEIGHT: u16 = 4;
 
+    /// A special tile used to signal the end of a tile stream during rendering.
+    pub const SENTINEL: Self = Self::new(u16::MAX, u16::MAX, 0, 0);
+
     /// Create a new tile.
     /// `x` and `y` will be clamped to the largest possible coordinate if they are too large.
     ///
@@ -218,6 +374,15 @@
         // the in-memory representation.
         ((self.y as u64) << 48) | ((self.x as u64) << 32) | self.packed_winding_line_idx as u64
     }
+
+    /// Whether a tile is a sentinel tile
+    //
+    // A tile produced organically by a make_tiles call can never have this coordinate because of
+    // the division by tile size on creation, so checking on x is sufficient to identify it.
+    #[inline(always)]
+    pub const fn is_sentinel(&self) -> bool {
+        self.x == u16::MAX
+    }
 }
 
 impl PartialEq for Tile {
@@ -249,15 +414,18 @@
     tile_buf: Vec<Tile>,
     level: Level,
     sorted: bool,
+    /// Auxiliary data tracking row windings and active rows for early culling.
+    pub windings: CulledWindings,
 }
 
 impl Tiles {
     /// Create a new tiles container.
-    pub fn new(level: Level) -> Self {
+    pub fn new(level: Level, height: u16) -> Self {
         Self {
             tile_buf: vec![],
             level,
             sorted: false,
+            windings: CulledWindings::new(height),
         }
     }
 
@@ -271,8 +439,14 @@
         self.tile_buf.is_empty()
     }
 
+    /// Returns `true` if any geometry was early-culled outside the viewport.
+    pub fn has_culled_tiles(&self) -> bool {
+        self.windings.culled
+    }
+
     /// Reset the tiles' container.
     pub fn reset(&mut self) {
+        self.windings.reset();
         self.tile_buf.clear();
         self.sorted = false;
     }
@@ -314,15 +488,32 @@
     /// function performs "coarse binning" to simply identify every tile a line segment traverses.
     /// It encodes the line index and winding direction, delegating the precise calculation of pixel
     /// coverage to `strip::render`.
-    //
-    // TODO: Tiles are clamped to the left edge of the viewport, but lines fully to the left of the
-    // viewport are not culled yet. These lines impact winding, and would need forwarding of
-    // winding to the strip generation stage.
-    pub fn make_tiles_analytic_aa(&mut self, lines: &[Line], width: u16, height: u16) {
+    pub fn make_tiles_analytic_aa(
+        &mut self,
+        level: Level,
+        lines: &[Line],
+        width: u16,
+        height: u16,
+    ) -> bool {
+        dispatch!(level, simd => self.make_tiles_analytic_aa_impl::<_>(
+            simd,
+            lines,
+            width,
+            height,
+        ))
+    }
+
+    fn make_tiles_analytic_aa_impl<S: Simd>(
+        &mut self,
+        s: S,
+        lines: &[Line],
+        width: u16,
+        height: u16,
+    ) -> bool {
         self.reset();
 
         if width == 0 || height == 0 {
-            return;
+            return self.windings.culled;
         }
 
         debug_assert!(
@@ -335,6 +526,11 @@
         let tile_columns = width.div_ceil(Tile::WIDTH);
         let tile_rows = height.div_ceil(Tile::HEIGHT);
 
+        let px_top = f32x4::from_slice(s, &[0.0, 1.0, 2.0, 3.0]);
+        let px_bottom = px_top + f32x4::splat(s, 1.0);
+        let simd_zero = f32x4::splat(s, 0.0);
+        let tile_height_f32 = Tile::HEIGHT as f32;
+
         for (line_idx, line) in lines.iter().take(MAX_LINES_PER_PATH as usize).enumerate() {
             let line_idx = line_idx as u32;
 
@@ -374,7 +570,94 @@
                 continue;
             }
 
-            // Get tile coordinates for start/end points, use i32 to preserve negative coordinates
+            let dir = if p0_y >= p1_y { 1 } else { -1 };
+            let f_dir = dir as f32;
+            let f_dir_v = f32x4::splat(s, f_dir);
+
+            macro_rules! calc_fractional_coverage {
+                ($y_idx:expr, $segment_top_y:expr, $segment_bottom_y:expr) => {{
+                    let y_idx_f32 = f32::from($y_idx);
+                    let local_y_start = ($segment_top_y - y_idx_f32) * tile_height_f32;
+                    let local_y_end = ($segment_bottom_y - y_idx_f32) * tile_height_f32;
+
+                    let start_v = f32x4::splat(s, local_y_start);
+                    let end_v = f32x4::splat(s, local_y_end);
+
+                    (px_bottom.min(end_v) - px_top.max(start_v)).max(simd_zero)
+                }};
+            }
+
+            // Lines fully to the left of the viewport are not visible but still produce winding
+            // which we record here and forward to the rendering stage.
+            if line_right_x < 0.0 {
+                let is_start_culled = line_top_y < 0.0;
+
+                // This branch is for handling the "start" of the line. In case
+                // the line reaches above the viewport, we are already in the
+                // middle so we can skip that part.
+                if !is_start_culled {
+                    self.windings.mark_row_active(y_top_tiles as usize);
+
+                    // Note: In theory, == should be enough, but just as
+                    // additional safety against numerical precision errors we
+                    // use <=.
+                    let at_top_of_tile = line_top_y <= f32::from(y_top_tiles);
+                    if at_top_of_tile {
+                        self.windings.coarse[y_top_tiles as usize] += dir;
+                    }
+
+                    let fractional_coverage =
+                        calc_fractional_coverage!(y_top_tiles, line_top_y, line_bottom_y);
+                    let target_row = &mut self.windings.partial[y_top_tiles as usize];
+                    let current = f32x4::from_slice(s, target_row);
+
+                    // See comment below on the double counting risk!
+                    let double_count = if at_top_of_tile {
+                        f_dir_v
+                    } else {
+                        f32x4::splat(s, 0.0)
+                    };
+                    let next = fractional_coverage.mul_add(f_dir_v, current - double_count);
+                    next.store_slice(target_row);
+                }
+
+                let y_start_middle = if is_start_culled {
+                    y_top_tiles
+                } else {
+                    y_top_tiles + 1
+                };
+                let line_bottom_floor = line_bottom_y.floor();
+                let y_end_middle = (line_bottom_floor as u16).min(tile_rows);
+
+                for y_idx in y_start_middle..y_end_middle {
+                    self.windings.coarse[y_idx as usize] += dir;
+                }
+                self.windings
+                    .mark_row_range_active(y_start_middle as usize, y_end_middle as usize);
+
+                if line_bottom_y != line_bottom_floor
+                    && y_end_middle < tile_rows
+                    // Prevent double-processing, unless the start was off-screen and hasn't been
+                    // handled yet.
+                    && (is_start_culled || y_end_middle != y_top_tiles)
+                {
+                    self.windings.mark_row_active(y_end_middle as usize);
+                    // Ends implicitly cross the top.
+                    self.windings.coarse[y_end_middle as usize] += dir;
+                    let fractional_coverage =
+                        calc_fractional_coverage!(y_end_middle, line_top_y, line_bottom_y);
+                    let target_row = &mut self.windings.partial[y_end_middle as usize];
+                    let current = f32x4::from_slice(s, target_row);
+                    // Subtract the inverse direction to avoid double counting with the coarse winding.
+                    let next = fractional_coverage.mul_add(f_dir_v, current - f_dir_v);
+                    next.store_slice(target_row);
+                }
+
+                self.windings.culled = true;
+                continue;
+            }
+
+            // Get tile coordinates for start/end points, use i32 to preserve negative coordinates.
             let p0_tile_x = line_top_x.floor() as i32;
             let p0_tile_y = line_top_y.floor() as i32;
             let p1_tile_x = line_bottom_x.floor() as i32;
@@ -383,7 +666,8 @@
             // Special-case out lines which are fully contained within a tile.
             let not_same_tile = p0_tile_y != p1_tile_y || p0_tile_x != p1_tile_x;
             if not_same_tile {
-                // For ease of logic, special-case purely vertical tiles.
+                // Case vertical lines: By definition, these cannot be horizontally crossing, and
+                // thus require no additional left-edge culling handling.
                 if line_left_x == line_right_x {
                     let x = (line_left_x as u16).min(tile_columns.saturating_sub(1));
 
@@ -403,22 +687,13 @@
                     } else {
                         y_top_tiles + 1
                     };
-                    let line_bottom_floor = line_bottom_y.floor();
-                    let y_end_idx = (line_bottom_floor as u16).min(tile_rows);
 
-                    for y_idx in y_start..y_end_idx {
+                    for y_idx in y_start..y_bottom_tiles {
                         let tile = Tile::new_clamped(x, y_idx, line_idx, W);
                         self.tile_buf.push(tile);
                     }
-
-                    // Row End, handle the final tile (y_end_idx), but *only* if the line does
-                    // not perfectly end on the top edge of the tile. In the case that it does,
-                    // it gets handled by the middle logic above.
-                    if line_bottom_y != line_bottom_floor && y_end_idx < tile_rows {
-                        let tile = Tile::new_clamped(x, y_end_idx, line_idx, W);
-                        self.tile_buf.push(tile);
-                    }
                 } else {
+                    // General case, any line which crosses more than one tile and is not vertical.
                     let dx = p1_x - p0_x;
                     let dy = p1_y - p0_y;
                     let x_slope = dx / dy;
@@ -440,6 +715,63 @@
                         let row_left_x = f32::min(row_top_x, row_bottom_x).max(line_left_x);
                         let row_right_x = f32::max(row_top_x, row_bottom_x).min(line_right_x);
 
+                        if row_left_x < 0.0 {
+                            self.windings.culled = true;
+
+                            if row_right_x < 0.0 {
+                                // Although the line may cross the left edge, the rightmost point in
+                                // this row may still be fully left of the viewport. In this case,
+                                // record the winding and emit no tiles.
+                                self.windings.mark_row_active(y_idx as usize);
+
+                                let crosses_top = (w_single & W) != 0;
+                                if crosses_top {
+                                    self.windings.coarse[y_idx as usize] += dir;
+                                }
+
+                                let fractional_coverage =
+                                    calc_fractional_coverage!(y_idx, row_top_y, row_bottom_y);
+                                let target_row = &mut self.windings.partial[y_idx as usize];
+                                let current = f32x4::from_slice(s, target_row);
+
+                                let double_count = if crosses_top {
+                                    f_dir_v
+                                } else {
+                                    f32x4::splat(s, 0.0)
+                                };
+                                let next =
+                                    fractional_coverage.mul_add(f_dir_v, current - double_count);
+                                next.store_slice(target_row);
+
+                                return;
+                            } else {
+                                // The line crosses into the viewport in this row. Record only the
+                                // fractional portion of the winding, as the coarse winding will
+                                // naturally get included by the clamped tile logic!
+                                let y_slope = dy / dx;
+                                let y_intersect = row_top_y - (row_top_x * y_slope);
+
+                                let (off_screen_top_y, off_screen_bottom_y) = if row_top_x < 0.0 {
+                                    (row_top_y, f32::min(row_bottom_y, y_intersect))
+                                } else {
+                                    (f32::max(row_top_y, y_intersect), row_bottom_y)
+                                };
+
+                                if off_screen_top_y < off_screen_bottom_y {
+                                    self.windings.mark_row_active(y_idx as usize);
+                                    let fractional_coverage = calc_fractional_coverage!(
+                                        y_idx,
+                                        off_screen_top_y,
+                                        off_screen_bottom_y
+                                    );
+                                    let target_row = &mut self.windings.partial[y_idx as usize];
+                                    let current = f32x4::from_slice(s, target_row);
+                                    let next = fractional_coverage.mul_add(f_dir_v, current);
+                                    next.store_slice(target_row);
+                                }
+                            }
+                        }
+
                         let x_start = row_left_x as u16;
                         let x_end = (row_right_x as u16).min(tile_columns.saturating_sub(1));
 
@@ -474,31 +806,20 @@
                         );
                     }
 
-                    let y_start_middle = if is_start_culled {
+                    let y_start = if is_start_culled {
                         y_top_tiles
                     } else {
                         y_top_tiles + 1
                     };
 
-                    let line_bottom_floor = line_bottom_y.floor();
-                    let y_end_middle = (line_bottom_floor as u16).min(tile_rows);
-                    for y_idx in y_start_middle..y_end_middle {
+                    for y_idx in y_start..y_bottom_tiles {
                         let y = f32::from(y_idx);
                         let row_bottom_y = (y + 1.0).min(line_bottom_y);
                         push_row(y_idx, y, row_bottom_y, w_start_base, w_end_base, W);
                     }
-
-                    if line_bottom_y != line_bottom_floor
-                        && y_end_middle < tile_rows
-                        && (is_start_culled || y_end_middle != y_top_tiles)
-                    {
-                        let y_idx = y_end_middle;
-                        let y = f32::from(y_idx);
-                        push_row(y_idx, y, line_bottom_y, w_start_base, w_end_base, W);
-                    }
                 }
             } else {
-                // Case: Line is fully contained within a single tile.
+                // Case line is fully contained within a single tile: These also cannot cross edges!
                 let tile = Tile::new_clamped(
                     (line_left_x as u16).min(tile_columns + 1),
                     y_top_tiles,
@@ -508,6 +829,8 @@
                 self.tile_buf.push(tile);
             }
         }
+
+        self.windings.culled
     }
 
     /// Generates tile commands for MSAA (Multisample Anti-Aliasing) rasterization.
@@ -875,9 +1198,10 @@
     use crate::flatten::{FlattenCtx, Line, Point, fill};
     use crate::geometry::RectU16;
     use crate::kurbo::{Affine, BezPath};
+    use crate::tile::CulledWindings;
     use crate::tile::{B, L, R, T, Tile, Tiles, W};
     use fearless_simd::Level;
-    use std::vec;
+    use std::vec::Vec;
 
     const VIEW_DIM: u16 = 100;
     const F_V_DIM: f32 = VIEW_DIM as f32;
@@ -893,7 +1217,7 @@
             self.make_tiles_msaa(lines, width, height);
             assert_eq!(self.tile_buf, expected, "MSAA: Tile buffer mismatch");
 
-            self.make_tiles_analytic_aa(lines, width, height);
+            self.make_tiles_analytic_aa(Level::baseline(), lines, width, height);
             check_analytic_aa_matches(&self.tile_buf, expected);
         }
     }
@@ -971,7 +1295,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]);
     }
 
@@ -996,7 +1320,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, W | T),
             Tile::new(1, 0, 1, W | T),
@@ -1042,7 +1366,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(1, 24, 0, B),
             Tile::new(2, 24, 1, B),
@@ -1065,7 +1389,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, W | T | R),
             Tile::new(1, 0, 0, L | B),
@@ -1096,7 +1420,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 23, 0, B),
             Tile::new(0, 24, 0, W | T | R),
@@ -1127,7 +1451,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(24, 0, 0, R),
             Tile::new(23, 0, 1, R),
@@ -1154,7 +1478,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, L),
             Tile::new(0, 0, 1, L | R),
@@ -1177,7 +1501,7 @@
             p1: Point { x: 90.0, y: -5.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]);
     }
 
@@ -1194,7 +1518,7 @@
             },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]);
     }
 
@@ -1205,7 +1529,7 @@
             p1: Point { x: 10.0, y: 10.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 2, 0, L | R),
             Tile::new(1, 2, 0, L | R),
@@ -1228,7 +1552,7 @@
             },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(23, 2, 0, R), Tile::new(24, 2, 0, L | R)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1253,7 +1577,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &[]);
     }
 
@@ -1263,7 +1587,7 @@
         const VIEWPORT_HEIGHT: u16 = 10;
 
         let path = BezPath::from_svg("M261,0 L78848,0 L78848,4 L261,4 Z").unwrap();
-        let mut line_buf = vec![];
+        let mut line_buf: Vec<Line> = Vec::new();
         fill(
             Level::try_detect().unwrap_or(Level::baseline()),
             &path,
@@ -1273,7 +1597,7 @@
             RectU16::new(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
         );
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         tiles.assert_tiles_match(&line_buf, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, &[]);
     }
 
@@ -1294,7 +1618,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, W | T),
             Tile::new(0, 0, 1, W | B | T),
@@ -1331,7 +1655,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 24, 0, B),
             Tile::new(0, 23, 1, B),
@@ -1348,7 +1672,7 @@
             p1: Point { x: 2.0, y: -1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, W | L | T)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1367,7 +1691,7 @@
             },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(24, 24, 0, R | B)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1383,7 +1707,7 @@
             p1: Point { x: 8.5, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, R | L),
@@ -1400,7 +1724,7 @@
             p1: Point { x: 1.5, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, R | L),
@@ -1417,7 +1741,7 @@
             p1: Point { x: 12.5, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, R | L),
@@ -1435,7 +1759,7 @@
             p1: Point { x: 1.0, y: 8.5 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, B),
             Tile::new(0, 1, 0, W | T | B),
@@ -1452,7 +1776,7 @@
             p1: Point { x: 1.0, y: 13.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, B),
             Tile::new(0, 1, 0, W | T | B),
@@ -1470,7 +1794,7 @@
             p1: Point { x: 1.0, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, B),
             Tile::new(0, 1, 0, W | T | B),
@@ -1488,7 +1812,7 @@
             p1: Point { x: 1.0, y: 1.5 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, B),
             Tile::new(0, 1, 0, W | T | B),
@@ -1506,7 +1830,7 @@
             p1: Point { x: 1.0, y: 8.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, B), Tile::new(0, 1, 0, W | T)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1519,7 +1843,7 @@
             p1: Point { x: 1.0, y: 7.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, W | B), Tile::new(0, 1, 0, W | T)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1535,7 +1859,7 @@
             p1: Point { x: 11.0, y: 9.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, L | B),
@@ -1554,7 +1878,7 @@
             p1: Point { x: 1.0, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, L | B),
@@ -1573,7 +1897,7 @@
             p1: Point { x: 14.0, y: 6.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(2, 1, 0, R | B),
             Tile::new(3, 1, 0, L),
@@ -1592,7 +1916,7 @@
             p1: Point { x: 2.0, y: 11.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(2, 1, 0, R | B),
             Tile::new(3, 1, 0, L),
@@ -1617,7 +1941,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, 0), Tile::new(0, 0, 1, 0)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1630,7 +1954,7 @@
             p1: Point { x: 5.0, y: 3.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(1, 0, 0, L),
             Tile::new(0, 1, 0, R),
@@ -1647,7 +1971,7 @@
             p1: Point { x: 0.1, y: 0.1 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, L),
@@ -1664,7 +1988,7 @@
             p1: Point { x: 9.0, y: 9.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(1, 1, 0, R),
             Tile::new(2, 1, 0, L),
@@ -1681,7 +2005,7 @@
             p1: Point { x: 9.0, y: 5.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(1, 1, 0, R | B),
             Tile::new(2, 1, 0, L),
@@ -1698,7 +2022,7 @@
             p1: Point { x: 4.0, y: 4.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, W | R), Tile::new(1, 0, 0, L)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1711,7 +2035,7 @@
             p1: Point { x: 4.0, y: 0.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, R), Tile::new(1, 0, 0, W | L)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1724,7 +2048,7 @@
             p1: Point { x: 8.0, y: 8.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, W | R),
             Tile::new(1, 0, 0, L),
@@ -1742,7 +2066,7 @@
             p1: Point { x: 8.0, y: 0.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(1, 0, 0, R | L),
             Tile::new(2, 0, 0, W | L),
@@ -1760,7 +2084,7 @@
             p1: Point { x: 8.0, y: 2.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R),
             Tile::new(1, 0, 0, R | L),
@@ -1777,7 +2101,7 @@
             p1: Point { x: 4.0, y: 0.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, R | B),
             Tile::new(1, 0, 0, W | L),
@@ -1794,7 +2118,7 @@
             p1: Point { x: 4.0, y: 8.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [
             Tile::new(0, 0, 0, W | B),
             Tile::new(0, 1, 0, W | R | T),
@@ -1814,7 +2138,7 @@
             p1: Point { x: 3.0, y: 3.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, 0)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1827,7 +2151,7 @@
             p1: Point { x: 3.0, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, 0)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1840,7 +2164,7 @@
             p1: Point { x: 1.0, y: 3.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, W)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1853,7 +2177,7 @@
             p1: Point { x: 4.0, y: 1.0 },
         }];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, R), Tile::new(1, 0, 0, L)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1872,7 +2196,7 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, 0), Tile::new(0, 0, 1, 0)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
@@ -1891,13 +2215,95 @@
             },
         ];
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         let expected = [Tile::new(0, 0, 0, W), Tile::new(0, 0, 1, W)];
 
         tiles.assert_tiles_match(&lines, VIEW_DIM, VIEW_DIM, &expected);
     }
 
     //==============================================================================================
+    // CulledWindings & Row Marking Logic
+    //==============================================================================================
+    #[test]
+    fn test_culled_windings_new_and_reset() {
+        let mut windings = CulledWindings::new(100);
+        assert_eq!(windings.partial.len(), 25);
+        assert_eq!(windings.coarse.len(), 25);
+        assert_eq!(windings.active.len(), 1);
+
+        windings.coarse[0] = 1;
+        windings.active[0] = 0xFF;
+        windings.culled = true;
+
+        windings.reset();
+        assert_eq!(windings.coarse[0], 0);
+        assert_eq!(windings.active[0], 0);
+
+        windings.coarse[0] = 1;
+        windings.active[0] = 0xFF;
+        windings.culled = false;
+
+        windings.reset();
+        assert_eq!(windings.coarse[0], 1);
+        assert_eq!(windings.active[0], 0xFF);
+    }
+
+    #[test]
+    fn test_mark_row_active() {
+        let mut windings = CulledWindings::new(200);
+        windings.mark_row_active(0);
+        windings.mark_row_active(5);
+        windings.mark_row_active(31);
+        windings.mark_row_active(32);
+        assert_eq!(windings.active[0], (1 << 0) | (1 << 5) | (1 << 31));
+        assert_eq!(windings.active[1], 1 << 0);
+    }
+
+    #[test]
+    fn test_mark_row_range_single_word() {
+        let mut windings = CulledWindings::new(200);
+        windings.mark_row_range_active(5, 10);
+        let expected_mask = ((1_u32 << 5) - 1) << 5;
+        assert_eq!(windings.active[0], expected_mask);
+        assert_eq!(windings.active[1], 0);
+    }
+
+    #[test]
+    fn test_mark_row_range_full_word() {
+        let mut windings = CulledWindings::new(200);
+        windings.mark_row_range_active(0, 32);
+        assert_eq!(windings.active[0], u32::MAX);
+        assert_eq!(windings.active[1], 0);
+    }
+
+    #[test]
+    fn test_mark_row_range_spanning_two_words() {
+        let mut windings = CulledWindings::new(200);
+        windings.mark_row_range_active(30, 35);
+        assert_eq!(windings.active[0], (1 << 30) | (1 << 31));
+        assert_eq!(windings.active[1], (1 << 0) | (1 << 1) | (1 << 2));
+    }
+
+    #[test]
+    fn test_mark_row_range_spanning_multiple_words() {
+        let mut windings = CulledWindings::new(500);
+        windings.mark_row_range_active(10, 80);
+        assert_eq!(windings.active[0], u32::MAX << 10);
+        assert_eq!(windings.active[1], u32::MAX);
+        assert_eq!(windings.active[2], (1 << 16) - 1);
+        assert_eq!(windings.active[3], 0);
+    }
+
+    #[test]
+    fn test_mark_row_range_empty_or_invalid() {
+        let mut windings = CulledWindings::new(200);
+        windings.mark_row_range_active(10, 10);
+        windings.mark_row_range_active(15, 10);
+        assert_eq!(windings.active[0], 0);
+        assert_eq!(windings.active[1], 0);
+    }
+
+    //==============================================================================================
     // Miscellaneous Cases
     //==============================================================================================
     #[test]
@@ -1908,9 +2314,9 @@
             p1: Point { x: 224.0, y: 388.0 },
         };
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
         tiles.make_tiles_msaa(&[line], 600, 600);
-        tiles.make_tiles_analytic_aa(&[line], 600, 600);
+        tiles.make_tiles_analytic_aa(Level::baseline(), &[line], 600, 600);
     }
 
     #[test]
@@ -1927,15 +2333,15 @@
             },
         };
 
-        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()));
-        tiles.make_tiles_analytic_aa(&[line], 200, 100);
+        let mut tiles = Tiles::new(Level::try_detect().unwrap_or(Level::baseline()), VIEW_DIM);
+        tiles.make_tiles_analytic_aa(Level::baseline(), &[line], 200, 100);
         tiles.make_tiles_msaa(&[line], 200, 100);
     }
 
     #[test]
     fn sort_test() {
-        let mut lines = vec![];
-        let mut tiles = Tiles::new(Level::baseline());
+        let mut lines: Vec<Line> = Vec::new();
+        let mut tiles = Tiles::new(Level::baseline(), VIEW_DIM);
 
         let step = 4.0;
         let mut y = F_V_DIM - 10.0;
@@ -1964,7 +2370,7 @@
         tiles.sort_tiles();
         check_sorted(&tiles.tile_buf);
 
-        tiles.make_tiles_analytic_aa(&lines, VIEW_DIM, VIEW_DIM);
+        tiles.make_tiles_analytic_aa(Level::baseline(), &lines, VIEW_DIM, VIEW_DIM);
         assert!(tiles.tile_buf.first().unwrap().y > tiles.tile_buf.last().unwrap().y);
         tiles.sort_tiles();
         check_sorted(&tiles.tile_buf);
diff --git a/sparse_strips/vello_sparse_tests/snapshots/left_cull_cross_left_combined.png b/sparse_strips/vello_sparse_tests/snapshots/left_cull_cross_left_combined.png
new file mode 100644
index 0000000..a83fbe3
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/left_cull_cross_left_combined.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f009df9c08a078229158b5700369e824846b2fffec4e1011cfd247bf2c5efec9
+size 498
diff --git a/sparse_strips/vello_sparse_tests/snapshots/left_cull_encloses_viewport.png b/sparse_strips/vello_sparse_tests/snapshots/left_cull_encloses_viewport.png
new file mode 100644
index 0000000..5720fda
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/left_cull_encloses_viewport.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:08b1eae2a44c2b0a3eef73084e417209b63135e14dd154b3b4018c3c2a1d7ce3
+size 83
diff --git a/sparse_strips/vello_sparse_tests/snapshots/left_cull_fully_left_combined.png b/sparse_strips/vello_sparse_tests/snapshots/left_cull_fully_left_combined.png
new file mode 100644
index 0000000..892c39b
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/left_cull_fully_left_combined.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7de95df168b01c3f367062eb9894c8c0180fbb73e8ef352fe7ad9ae55d83f573
+size 75
diff --git a/sparse_strips/vello_sparse_tests/snapshots/left_cull_mask_cross_combined.png b/sparse_strips/vello_sparse_tests/snapshots/left_cull_mask_cross_combined.png
new file mode 100644
index 0000000..8223015
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/left_cull_mask_cross_combined.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e790fd76f8fee019a6a631cb9364e89b436797724fa500609c9a12ced7894f81
+size 564
diff --git a/sparse_strips/vello_sparse_tests/snapshots/left_cull_mask_encloses_viewport.png b/sparse_strips/vello_sparse_tests/snapshots/left_cull_mask_encloses_viewport.png
new file mode 100644
index 0000000..6060450
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/left_cull_mask_encloses_viewport.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:037834adb99385717d58895fef4ddc03f731deca6c8901e65fa53854f9487cf7
+size 83
diff --git a/sparse_strips/vello_sparse_tests/snapshots/left_cull_triangle_expands_below_viewport.png b/sparse_strips/vello_sparse_tests/snapshots/left_cull_triangle_expands_below_viewport.png
new file mode 100644
index 0000000..c8a3313
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/left_cull_triangle_expands_below_viewport.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d149bb0e1b096bb5ebc86f2ec1a6f670e709421d293677c558f4bcc18bc113dd
+size 374
diff --git a/sparse_strips/vello_sparse_tests/tests/basic.rs b/sparse_strips/vello_sparse_tests/tests/basic.rs
index ba7ce66..4d98383 100644
--- a/sparse_strips/vello_sparse_tests/tests/basic.rs
+++ b/sparse_strips/vello_sparse_tests/tests/basic.rs
@@ -628,3 +628,141 @@
         "composite_to_pixmap_at_offset result should match direct rendering"
     );
 }
+
+#[vello_test(width = 30, height = 60)]
+fn left_cull_fully_left_combined(ctx: &mut impl Renderer) {
+    ctx.set_paint(REBECCA_PURPLE);
+
+    let rect_top = Rect::new(-40.0, -10.0, -10.0, 20.0);
+    ctx.set_transform(Affine::rotate_about(
+        15.0 * PI / 180.0,
+        Point::new(-25.0, 5.0),
+    ));
+    ctx.fill_rect(&rect_top);
+
+    let rect_bot = Rect::new(-40.0, 40.0, -10.0, 70.0);
+    ctx.set_transform(Affine::rotate_about(
+        -15.0 * PI / 180.0,
+        Point::new(-25.0, 55.0),
+    ));
+    ctx.fill_rect(&rect_bot);
+}
+
+#[vello_test(width = 30, height = 100)]
+fn left_cull_cross_left_combined(ctx: &mut impl Renderer) {
+    ctx.set_paint(REBECCA_PURPLE);
+
+    let rect_top = Rect::new(-15.0, -15.0, 15.0, 15.0);
+    ctx.set_transform(Affine::rotate_about(
+        10.0 * PI / 180.0,
+        Point::new(0.0, 0.0),
+    ));
+    ctx.fill_rect(&rect_top);
+
+    let rect_mid = Rect::new(-20.0, 35.0, 20.0, 55.0);
+    ctx.set_transform(Affine::rotate_about(
+        5.0 * PI / 180.0,
+        Point::new(0.0, 45.0),
+    ));
+    ctx.fill_rect(&rect_mid);
+
+    let rect_bot = Rect::new(-15.0, 75.0, 15.0, 105.0);
+    ctx.set_transform(Affine::rotate_about(
+        -10.0 * PI / 180.0,
+        Point::new(0.0, 90.0),
+    ));
+    ctx.fill_rect(&rect_bot);
+}
+
+#[vello_test(width = 30, height = 60)]
+fn left_cull_triangle_expands_below_viewport(ctx: &mut impl Renderer) {
+    let mut path = BezPath::new();
+    path.move_to((15.0, 2.0));
+    path.line_to((52.0, 72.0));
+    path.line_to((-22.0, 72.0));
+    path.close_path();
+
+    ctx.set_paint(REBECCA_PURPLE);
+    ctx.fill_path(&path);
+}
+
+#[vello_test(width = 30, height = 30)]
+fn left_cull_encloses_viewport(ctx: &mut impl Renderer) {
+    let rect = Rect::new(-50.0, -50.0, 80.0, 80.0);
+
+    ctx.set_transform(Affine::rotate_about(
+        7.0 * PI / 180.0,
+        Point::new(15.0, 15.0),
+    ));
+    ctx.set_paint(REBECCA_PURPLE);
+    ctx.fill_rect(&rect);
+}
+
+#[vello_test(width = 30, height = 100)]
+fn left_cull_mask_cross_combined(ctx: &mut impl Renderer) {
+    let transform = Affine::new([0.9848077, 0.17364818, -0.17364818, 0.9848077, 0.0, 0.0]);
+    let rect_path = Rect::new(0.0, 0.0, 30.0, 100.0).to_path(0.1);
+
+    let mut clip_path = BezPath::new();
+    clip_path.move_to((0.0, 100.0));
+    clip_path.line_to((30.0, 100.0));
+    clip_path.line_to((30.0, 0.0));
+    clip_path.line_to((0.0, 0.0));
+    clip_path.close_path();
+
+    let mut mask_clip = BezPath::new();
+
+    mask_clip.move_to((-10.0, -10.0));
+    mask_clip.line_to((15.0, -10.0));
+    mask_clip.line_to((20.0, 25.0));
+    mask_clip.line_to((-15.0, 25.0));
+    mask_clip.close_path();
+
+    mask_clip.move_to((-2.4334785, 31.524632));
+    mask_clip.line_to((12.338636, 34.129355));
+    mask_clip.line_to((6.0873017, 69.58243));
+    mask_clip.line_to((-8.6848135, 66.97771));
+    mask_clip.close_path();
+
+    mask_clip.move_to((-15.0, 75.0));
+    mask_clip.line_to((20.0, 75.0));
+    mask_clip.line_to((15.0, 115.0));
+    mask_clip.line_to((-10.0, 115.0));
+    mask_clip.close_path();
+
+    ctx.push_clip_path(&clip_path);
+    ctx.push_clip_path(&mask_clip);
+    ctx.set_paint(GREEN);
+    ctx.set_transform(transform);
+    ctx.fill_path(&rect_path);
+    ctx.pop_clip_path();
+    ctx.pop_clip_path();
+}
+
+#[vello_test(width = 30, height = 30)]
+fn left_cull_mask_encloses_viewport(ctx: &mut impl Renderer) {
+    let transform = Affine::new([0.9848077, 0.17364818, -0.17364818, 0.9848077, 0.0, 0.0]);
+    let rect_path = Rect::new(-20.0, -20.0, 50.0, 50.0).to_path(0.1);
+
+    let mut clip_path = BezPath::new();
+    clip_path.move_to((0.0, 30.0));
+    clip_path.line_to((30.0, 30.0));
+    clip_path.line_to((30.0, 0.0));
+    clip_path.line_to((0.0, 0.0));
+    clip_path.close_path();
+
+    let mut mask_clip = BezPath::new();
+    mask_clip.move_to((-40.0, -40.0));
+    mask_clip.line_to((70.0, -40.0));
+    mask_clip.line_to((70.0, 70.0));
+    mask_clip.line_to((-40.0, 70.0));
+    mask_clip.close_path();
+
+    ctx.push_clip_path(&clip_path);
+    ctx.push_clip_path(&mask_clip);
+    ctx.set_paint(GREEN);
+    ctx.set_transform(transform);
+    ctx.fill_path(&rect_path);
+    ctx.pop_clip_path();
+    ctx.pop_clip_path();
+}
diff --git a/sparse_strips/vello_toy/src/debug.rs b/sparse_strips/vello_toy/src/debug.rs
index 7ff5ce1..f6dd158 100644
--- a/sparse_strips/vello_toy/src/debug.rs
+++ b/sparse_strips/vello_toy/src/debug.rs
@@ -33,7 +33,7 @@
         Document::new().set("viewBox", (-10, -10, args.width + 20, args.height + 20));
 
     let mut line_buf = vec![];
-    let mut tiles = Tiles::new(Level::new());
+    let mut tiles = Tiles::new(Level::new(), args.height);
     let mut strip_buf = vec![];
     let mut alpha_buf = vec![];
     let mut wide = Wide::<MODE_CPU>::new(args.width, args.height);
@@ -74,7 +74,7 @@
     }
 
     if stages.iter().any(|s| s.requires_tiling()) {
-        tiles.make_tiles_analytic_aa(&line_buf, args.width, args.height);
+        tiles.make_tiles_analytic_aa(Level::new(), &line_buf, args.width, args.height);
         tiles.sort_tiles();
     }