blob: 4ee6687621f31db79a08f2790b70a05b1c146066 [file]
// Copyright 2025 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! Generating and processing wide tiles.
use crate::color::palette::css::TRANSPARENT;
use crate::encode::EncodedPaint;
use crate::filter_effects::Filter;
use crate::kurbo::{Affine, Rect};
use crate::mask::Mask;
use crate::paint::{Paint, PremulColor};
use crate::peniko::{BlendMode, Compose, Mix};
use crate::render_graph::{DependencyKind, LayerId, RenderGraph, RenderNodeKind};
use crate::{strip::Strip, tile::Tile};
use alloc::vec;
use alloc::{boxed::Box, vec::Vec};
#[cfg(debug_assertions)]
use alloc::{format, string::String};
use core::ops::Range;
use hashbrown::HashMap;
#[cfg(not(feature = "std"))]
use peniko::kurbo::common::FloatFuncs as _;
#[derive(Debug)]
struct Layer {
/// The layer's ID.
layer_id: LayerId,
/// Whether the layer has a clip associated with it.
clip: bool,
/// The blend mode with which this layer should be blended into
/// the previous layer.
blend_mode: BlendMode,
/// An opacity to apply to the whole layer before blending it
/// into the backdrop.
opacity: f32,
/// A mask to apply to the layer before blending it back into
/// the backdrop.
mask: Option<Mask>,
/// A filter effect to apply to the layer before other operations.
filter: Option<Filter>,
/// Bounding box of wide tiles containing geometry.
/// Starts with inverted bounds, shrinks to actual content during drawing.
wtile_bbox: WideTilesBbox,
}
impl Layer {
/// Whether the layer actually requires allocating a new scratch buffer
/// for drawing its contents.
fn needs_buf(&self) -> bool {
self.blend_mode.mix != Mix::Normal
|| self.blend_mode.compose != Compose::SrcOver
|| self.opacity != 1.0
|| self.mask.is_some()
|| self.filter.is_some()
|| !self.clip
}
}
/// `MODE_CPU` allows compile time optimizations to be applied to wide tile draw command generation
/// specific to `vello_cpu`.
pub const MODE_CPU: u8 = 0;
/// `MODE_HYBRID` allows compile time optimizations to be applied to wide tile draw command
/// generation specific for `vello_hybrid`.
pub const MODE_HYBRID: u8 = 1;
/// A container for wide tiles.
#[derive(Debug)]
pub struct Wide<const MODE: u8 = MODE_CPU> {
/// The width of the container.
pub width: u16,
/// The height of the container.
pub height: u16,
/// The wide tiles in the container.
pub tiles: Vec<WideTile<MODE>>,
/// Shared command properties, referenced by index from fill and clip commands.
pub attrs: CommandAttrs,
/// The stack of layers.
layer_stack: Vec<Layer>,
/// The stack of active clip regions.
clip_stack: Vec<Clip>,
/// Stack of filter layer node IDs for render graph dependency tracking.
/// Initialized with node 0 (the root node representing the final output).
/// As layers with filters are pushed, their node IDs are added to this stack.
filter_node_stack: Vec<usize>,
/// Count of nested filtered layers with clip paths.
/// When > 0, command generation uses full viewport bounds instead of clip bounds
/// to ensure filter effects can process the full layer before applying the clip.
clipped_filter_layer_depth: u32,
}
/// A clip region.
#[derive(Debug)]
struct Clip {
/// The intersected bounding box after clip
pub clip_bbox: WideTilesBbox,
/// The rendered path in sparse strip representation
pub strips: Box<[Strip]>,
/// The index of the thread that owns the alpha buffer.
/// Always 0 in single-threaded mode.
pub thread_idx: u8,
}
/// An axis-aligned bounding box represented by top-left and bottom-right corners.
///
/// The coordinates are stored as `[x0, y0, x1, y1]` in wide tile coordinates,
/// where `(x0, y0)` is the top-left corner and `(x1, y1)` is the bottom-right corner.
#[derive(Debug, Clone, Copy)]
pub struct WideTilesBbox {
/// The bounding box coordinates.
pub bbox: [u16; 4],
}
impl WideTilesBbox {
/// Create a new bounding box.
pub fn new(bbox: [u16; 4]) -> Self {
Self { bbox }
}
/// Get the x0 coordinate of the bounding box.
#[inline(always)]
pub fn x0(&self) -> u16 {
self.bbox[0]
}
/// Get the y0 coordinate of the bounding box.
#[inline(always)]
pub fn y0(&self) -> u16 {
self.bbox[1]
}
/// Get the x1 coordinate of the bounding box.
#[inline(always)]
pub fn x1(&self) -> u16 {
self.bbox[2]
}
/// Get the y1 coordinate of the bounding box.
#[inline(always)]
pub fn y1(&self) -> u16 {
self.bbox[3]
}
/// Get the width of the bounding box (x1 - x0).
#[inline(always)]
pub fn width_tiles(&self) -> u16 {
self.x1().saturating_sub(self.x0())
}
/// Get the width of the bounding box in pixels.
#[inline(always)]
pub fn width_px(&self) -> u16 {
self.width_tiles() * WideTile::WIDTH
}
/// Get the height of the bounding box (y1 - y0).
#[inline(always)]
pub fn height_tiles(&self) -> u16 {
self.y1().saturating_sub(self.y0())
}
/// Get the height of the bounding box in pixels.
#[inline(always)]
pub fn height_px(&self) -> u16 {
self.height_tiles() * Tile::HEIGHT
}
/// Check if a point (x, y) is contained within this bounding box.
///
/// Returns `true` if x0 <= x < x1 and y0 <= y < y1.
#[inline(always)]
pub fn contains(&self, x: u16, y: u16) -> bool {
let [x0, y0, x1, y1] = self.bbox;
(x >= x0) & (x < x1) & (y >= y0) & (y < y1)
}
/// Create an empty bounding box (zero area).
#[inline(always)]
pub(crate) fn empty() -> Self {
Self::new([0, 0, 0, 0])
}
/// Calculate the intersection of this bounding box with another.
#[inline(always)]
pub(crate) fn intersect(self, other: Self) -> Self {
Self::new([
self.x0().max(other.x0()),
self.y0().max(other.y0()),
self.x1().min(other.x1()),
self.y1().min(other.y1()),
])
}
/// Update this bounding box to include another bounding box (union in place).
#[inline(always)]
pub(crate) fn union(&mut self, other: Self) {
if !other.is_inverted() {
if self.is_inverted() {
// If self is empty, just copy other
self.bbox = other.bbox;
} else {
// Otherwise compute the union
self.bbox[0] = self.bbox[0].min(other.x0());
self.bbox[1] = self.bbox[1].min(other.y0());
self.bbox[2] = self.bbox[2].max(other.x1());
self.bbox[3] = self.bbox[3].max(other.y1());
}
}
}
/// Create an inverted bounding box for incremental updates.
#[inline(always)]
pub(crate) fn inverted() -> Self {
Self::new([u16::MAX, u16::MAX, 0, 0])
}
/// Check if the bbox is still in its inverted state (no updates yet).
#[inline(always)]
pub(crate) fn is_inverted(self) -> bool {
self.bbox[0] == u16::MAX && self.bbox[1] == u16::MAX
}
/// Update the bbox to include the given tile coordinates.
#[inline(always)]
pub(crate) fn include_tile(&mut self, wtile_x: u16, wtile_y: u16) {
self.bbox[0] = self.bbox[0].min(wtile_x);
self.bbox[1] = self.bbox[1].min(wtile_y);
self.bbox[2] = self.bbox[2].max(wtile_x + 1);
self.bbox[3] = self.bbox[3].max(wtile_y + 1);
}
/// Scale this bounding box by the given scale factors.
///
/// Multiplies each coordinate by the corresponding scale factor to convert
/// from one coordinate system to another.
#[inline(always)]
pub fn scale(&self, scale_x: u16, scale_y: u16) -> [u32; 4] {
[
u32::from(self.x0()) * u32::from(scale_x),
u32::from(self.y0()) * u32::from(scale_y),
u32::from(self.x1()) * u32::from(scale_x),
u32::from(self.y1()) * u32::from(scale_y),
]
}
/// Expands the bounding box outward by the given pixel amounts in each direction.
///
/// Pixel values are converted to tile coordinates (rounding up) and clamped to the
/// valid range `[0, max_x)` × `[0, max_y)`. The result is a new bounding box in
/// wide tile coordinates.
pub fn expand_by_pixels(&self, expansion: Rect, max_x: u16, max_y: u16) -> Self {
// The expansion rect is centered at origin:
// - Negative coordinates (x0, y0) represent left/top expansion
// - Positive coordinates (x1, y1) represent right/bottom expansion
let left_px = (-expansion.x0).max(0.0).ceil() as u16;
let top_px = (-expansion.y0).max(0.0).ceil() as u16;
let right_px = expansion.x1.max(0.0).ceil() as u16;
let bottom_px = expansion.y1.max(0.0).ceil() as u16;
// Convert pixel expansion to tile expansion (round up)
let left_tiles = left_px.div_ceil(WideTile::WIDTH);
let top_tiles = top_px.div_ceil(Tile::HEIGHT);
let right_tiles = right_px.div_ceil(WideTile::WIDTH);
let bottom_tiles = bottom_px.div_ceil(Tile::HEIGHT);
Self::new([
self.x0().saturating_sub(left_tiles),
self.y0().saturating_sub(top_tiles),
(self.x1() + right_tiles).min(max_x),
(self.y1() + bottom_tiles).min(max_y),
])
}
}
impl Wide<MODE_CPU> {
/// Create a new container for wide tiles.
pub fn new(width: u16, height: u16) -> Self {
Self::new_internal(width, height)
}
}
impl Wide<MODE_HYBRID> {
/// Create a new container for wide tiles.
pub fn new(width: u16, height: u16) -> Self {
Self::new_internal(width, height)
}
}
impl<const MODE: u8> Wide<MODE> {
/// Create a new container for wide tiles.
fn new_internal(width: u16, height: u16) -> Self {
let width_tiles = width.div_ceil(WideTile::WIDTH);
let height_tiles = height.div_ceil(Tile::HEIGHT);
let mut tiles = Vec::with_capacity(usize::from(width_tiles) * usize::from(height_tiles));
for h in 0..height_tiles {
for w in 0..width_tiles {
tiles.push(WideTile::<MODE>::new_internal(
w * WideTile::WIDTH,
h * Tile::HEIGHT,
));
}
}
Self {
tiles,
width,
height,
attrs: CommandAttrs::default(),
layer_stack: vec![],
clip_stack: vec![],
// Start with root node 0.
filter_node_stack: vec![0],
clipped_filter_layer_depth: 0,
}
}
/// Whether there are any existing layers that haven't been popped yet.
pub fn has_layers(&self) -> bool {
!self.layer_stack.is_empty()
}
/// Reset all tiles in the container.
pub fn reset(&mut self) {
for tile in &mut self.tiles {
tile.bg = PremulColor::from_alpha_color(TRANSPARENT);
tile.cmds.clear();
tile.layer_ids.truncate(1);
tile.layer_cmd_ranges.clear();
tile.layer_cmd_ranges
.insert(0, LayerCommandRanges::default());
}
self.attrs.clear();
self.layer_stack.clear();
self.clip_stack.clear();
self.filter_node_stack.truncate(1);
self.clipped_filter_layer_depth = 0;
}
/// Return the number of horizontal tiles.
pub fn width_tiles(&self) -> u16 {
self.width.div_ceil(WideTile::WIDTH)
}
/// Return the number of vertical tiles.
pub fn height_tiles(&self) -> u16 {
self.height.div_ceil(Tile::HEIGHT)
}
/// Get the wide tile at a certain index.
///
/// Panics if the index is out-of-range.
pub fn get(&self, x: u16, y: u16) -> &WideTile<MODE> {
assert!(
x < self.width_tiles() && y < self.height_tiles(),
"attempted to access out-of-bounds wide tile"
);
&self.tiles[usize::from(y) * usize::from(self.width_tiles()) + usize::from(x)]
}
/// Get mutable access to the wide tile at a certain index.
///
/// Panics if the index is out-of-range.
pub fn get_mut(&mut self, x: u16, y: u16) -> &mut WideTile<MODE> {
assert!(
x < self.width_tiles() && y < self.height_tiles(),
"attempted to access out-of-bounds wide tile"
);
let idx = usize::from(y) * usize::from(self.width_tiles()) + usize::from(x);
&mut self.tiles[idx]
}
/// Return a reference to all wide tiles.
pub fn tiles(&self) -> &[WideTile<MODE>] {
self.tiles.as_slice()
}
/// Get the current layer Id.
#[inline(always)]
pub fn get_current_layer_id(&self) -> LayerId {
self.layer_stack.last().map_or(0, |l| l.layer_id)
}
/// Update the bounding box of the current layer to include the given tile.
/// Should be called whenever a command is generated for a tile.
#[inline]
fn update_current_layer_bbox(&mut self, wtile_x: u16, wtile_y: u16) {
if let Some(layer) = self.layer_stack.last_mut() {
layer.wtile_bbox.include_tile(wtile_x, wtile_y);
}
}
/// Generate wide tile commands from the strip buffer.
///
/// This method processes a buffer of strips that represent a path, applies the fill rule,
/// and generates appropriate drawing commands for each affected wide tile.
///
/// # Algorithm overview:
/// 1. For each strip in the buffer:
/// - Calculate its position and width in pixels
/// - Determine which wide tiles the strip intersects
/// - Generate alpha fill commands for the intersected wide tiles
/// 2. For active fill regions (determined by fill rule):
/// - Generate solid fill commands for the regions between strips
pub fn generate(
&mut self,
strip_buf: &[Strip],
paint: Paint,
blend_mode: BlendMode,
thread_idx: u8,
mask: Option<Mask>,
encoded_paints: &[EncodedPaint],
) {
if strip_buf.is_empty() {
return;
}
let alpha_base_idx = strip_buf[0].alpha_idx();
// Create shared attributes for all commands from this path
let attrs_idx = self.attrs.fill.len() as u32;
self.attrs.fill.push(FillAttrs {
thread_idx,
paint,
blend_mode,
mask,
alpha_base_idx,
});
// Get current clip bounding box or full viewport if no clip is active
let bbox = self.active_bbox();
// Save current_layer_id to avoid borrowing issues
let current_layer_id = self.get_current_layer_id();
for i in 0..strip_buf.len() - 1 {
let strip = &strip_buf[i];
debug_assert!(
strip.y < self.height,
"Strips below the viewport should have been culled prior to this stage."
);
// Don't render strips that are outside the viewport width
if strip.x >= self.width {
continue;
}
let next_strip = &strip_buf[i + 1];
let x0 = strip.x;
let strip_y = strip.strip_y();
// Skip strips outside the current clip bounding box
if strip_y < bbox.y0() {
continue;
}
if strip_y >= bbox.y1() {
// The rest of our strips must be outside the clip, so we can break early.
break;
}
// Calculate the width of the strip in columns
let mut col = strip.alpha_idx() / u32::from(Tile::HEIGHT);
let next_col = next_strip.alpha_idx() / u32::from(Tile::HEIGHT);
// Can potentially be 0 if strip only changes winding without covering pixels
let strip_width = next_col.saturating_sub(col) as u16;
let x1 = x0.saturating_add(strip_width);
// Calculate which wide tiles this strip intersects
let wtile_x0 = (x0 / WideTile::WIDTH).max(bbox.x0());
// It's possible that a strip extends into a new wide tile, but we don't actually
// have as many wide tiles (e.g. because the pixmap width is only 512, but
// strip ends at 513), so take the minimum between the rounded values and `width_tiles`.
let wtile_x1 = x1
.div_ceil(WideTile::WIDTH)
.min(bbox.x1())
.min(WideTile::MAX_WIDE_TILE_COORD);
// Adjust column starting position if needed to respect clip boundaries
let mut x = x0;
let clip_x = bbox.x0() * WideTile::WIDTH;
if clip_x > x {
col += u32::from(clip_x - x);
x = clip_x;
}
// Generate alpha fill commands for each wide tile intersected by this strip
for wtile_x in wtile_x0..wtile_x1 {
let x_wtile_rel = x % WideTile::WIDTH;
// Restrict the width of the fill to the width of the wide tile
let width = x1.min((wtile_x + 1) * WideTile::WIDTH) - x;
let cmd = CmdAlphaFill {
x: x_wtile_rel,
width,
alpha_offset: col * u32::from(Tile::HEIGHT) - alpha_base_idx,
attrs_idx,
};
x += width;
col += u32::from(width);
self.get_mut(wtile_x, strip_y).strip(cmd, current_layer_id);
self.update_current_layer_bbox(wtile_x, strip_y);
}
// Determine if the region between this strip and the next should be filled.
let active_fill = next_strip.fill_gap();
// If region should be filled and both strips are on the same row,
// generate fill commands for the region between them
if active_fill && strip_y == next_strip.strip_y() {
// Clamp the fill to the clip bounding box
x = x1.max(bbox.x0() * WideTile::WIDTH);
let x2 = next_strip.x.min(
self.width
.checked_next_multiple_of(WideTile::WIDTH)
.unwrap_or(u16::MAX),
);
let wfxt0 = (x1 / WideTile::WIDTH).max(bbox.x0());
let wfxt1 = x2
.div_ceil(WideTile::WIDTH)
.min(bbox.x1())
.min(WideTile::MAX_WIDE_TILE_COORD);
// Compute fill hint based on paint type
let fill_attrs = &self.attrs.fill[attrs_idx as usize];
let fill_hint = if fill_attrs.mask.is_none() {
match &fill_attrs.paint {
Paint::Solid(s) if s.is_opaque() => FillHint::OpaqueSolid(*s),
Paint::Indexed(idx) => {
if let Some(EncodedPaint::Image(img)) = encoded_paints.get(idx.index())
&& !img.has_opacities
&& img.sampler.alpha == 1.0
{
FillHint::OpaqueImage
} else {
FillHint::None
}
}
_ => FillHint::None,
}
} else {
FillHint::None
};
// Generate fill commands for each wide tile in the fill region
for wtile_x in wfxt0..wfxt1 {
let x_wtile_rel = x % WideTile::WIDTH;
let width = x2.min(
(wtile_x
.checked_add(1)
.unwrap_or(WideTile::MAX_WIDE_TILE_COORD))
* WideTile::WIDTH,
) - x;
x += width;
self.get_mut(wtile_x, strip_y).fill(
x_wtile_rel,
width,
attrs_idx,
current_layer_id,
fill_hint,
);
// TODO: This bbox update might be redundant since filled regions are always
// bounded by strip regions (which already update the bbox). Consider removing
// this in a follow-up with proper benchmarks to verify correctness.
self.update_current_layer_bbox(wtile_x, strip_y);
}
}
}
}
/// Push a new layer with the given properties.
///
/// Rendering will be directed to the layer storage identified by `layer_id`.
/// This is used for filter effects that require access to a fully-rendered layer.
///
/// If `filter` is Some, builds render graph nodes for filter effects.
pub fn push_layer(
&mut self,
layer_id: LayerId,
clip_path: Option<impl Into<Box<[Strip]>>>,
blend_mode: BlendMode,
mask: Option<Mask>,
opacity: f32,
filter: Option<Filter>,
transform: Affine,
render_graph: &mut RenderGraph,
thread_idx: u8,
) {
// Some explanations about what is going on here: We support the concept of
// layers, where a user can push a new layer (with certain properties), draw some
// stuff, and finally pop the layer, as part of which the layer as a whole will be
// blended into the previous layer.
// There are 3 "straightforward" properties that can be set for each layer:
// 1) The blend mode that should be used to blend the layer into the backdrop.
// 2) A mask that will be applied to the whole layer in the very end before blending.
// 3) An optional opacity that will be applied to the whole layer before blending (this
// could in theory be simulated with an alpha mask, but since it's a common operation and
// we only have a single opacity, this can easily be optimized.
//
// Finally, you can also add a clip path to the layer. However, clipping has its own
// more complicated logic for pushing/popping buffers where drawing is also suppressed
// in clipped-out wide tiles. Because of this, in case we have one of the above properties
// AND a clipping path, we will actually end up pushing two buffers, the first one handles
// the three properties and the second one is just for clip paths. That is a bit wasteful
// and I believe it should be possible to process them all in just one go, but for now
// this is good enough, and it allows us to implement blending without too deep changes to
// the original clipping implementation.
// Build render graph node ONLY if we have a filter.
// The render graph tracks dependencies and execution order for filter effects.
if let Some(filter) = &filter {
// Create a FilterLayer node that combines render + filter + other operations
let child_node = render_graph.add_node(RenderNodeKind::FilterLayer {
layer_id,
filter: filter.clone(),
// Bounding box starts inverted and will be updated in pop_layer with actual bounds
wtile_bbox: WideTilesBbox::inverted(),
transform,
});
// Connect to parent node if there is one
if let Some(&parent_node) = self.filter_node_stack.last() {
render_graph.add_edge(
child_node,
parent_node,
DependencyKind::Sequential { layer_id },
);
}
// Push this filter node onto the stack so subsequent filters depend on it
self.filter_node_stack.push(child_node);
}
let has_filter = filter.is_some();
let has_clip = clip_path.is_some();
let layer_kind = if has_filter {
LayerKind::Filtered(layer_id)
} else {
LayerKind::Regular(layer_id)
};
// Filtered layers with clipping require special handling: normally, tiles with
// zero-winding clips suppress all drawing. However, filters need the full layer
// content rendered (including zero-clipped areas) before applying the clip as a mask.
// When this flag is true, we generate explicit drawing commands instead of just counters.
let in_clipped_filter_layer = has_filter && has_clip;
// Increment the depth counter so that active_bbox() returns the full viewport
// instead of the clipped bbox. This ensures command generation covers all tiles,
// allowing the filter to process the entire layer before the clip is applied.
if in_clipped_filter_layer {
self.clipped_filter_layer_depth += 1;
}
let layer = Layer {
layer_id,
clip: has_clip,
blend_mode,
opacity,
mask,
filter,
wtile_bbox: WideTilesBbox::inverted(),
};
// In case we do blending, masking, opacity, or filtering, push one buffer per wide tile.
//
// Layers require buffers for different reasons:
// - Blending, opacity, and masking: Need to composite results with the backdrop
// - Filtering: Content must be rendered to a buffer before applying filter effects
//
// The layer_kind parameter distinguishes how buffers are managed:
// - Regular layers use the local blend_buf stack
// - Filtered layers are materialized in persistent layer storage for filter processing
// - Clip layers have special handling for clipping operations
if layer.needs_buf() {
for x in 0..self.width_tiles() {
for y in 0..self.height_tiles() {
let tile = self.get_mut(x, y);
tile.push_buf(layer_kind);
// Mark tiles that are in a clipped filter layer so they generate
// explicit clip commands for proper filter processing.
tile.in_clipped_filter_layer = in_clipped_filter_layer;
}
}
}
// If we have a clip path, push another buffer in the affected wide tiles.
// Note that it is important that we FIRST push the buffer for blending etc. and
// only then for clipping, otherwise we will use the empty clip buffer as the backdrop
// for blending!
if let Some(clip) = clip_path {
self.push_clip(clip, layer_id, thread_idx);
}
self.layer_stack.push(layer);
}
/// Pop a previously pushed layer.
///
/// This method finalizes the layer by:
/// - Expanding the bounding box if filter effects are present
/// - Updating the parent layer's bounding box to include this layer's bounds
/// - Completing render graph nodes for filter effects
/// - Generating filter commands for each tile
/// - Popping any associated clip
/// - Applying mask, opacity, and blend mode operations if needed
pub fn pop_layer(&mut self, render_graph: &mut RenderGraph) {
// This method basically unwinds everything we did in `push_layer`.
let mut layer = self.layer_stack.pop().unwrap();
if let Some(filter) = &layer.filter {
// Update render graph node with final bounding box
if let Some(node_id) = self.filter_node_stack.pop() {
// Get the transform from the FilterLayer node and scale the expansion by it
if let Some(node) = render_graph.nodes.get_mut(node_id)
&& let RenderNodeKind::FilterLayer {
wtile_bbox,
transform,
..
} = &mut node.kind
{
// Calculate expansion in device/pixel space, accounting for the full transform.
// This ensures that rotated filters (e.g., drop shadows) have correct bounds.
let expansion = filter.bounds_expansion(transform);
let expanded_bbox = layer.wtile_bbox.expand_by_pixels(
expansion,
self.width_tiles(),
self.height_tiles(),
);
let clip_bbox = self.active_bbox();
let final_bbox = expanded_bbox.intersect(clip_bbox);
// Update both the local layer and the render graph node
layer.wtile_bbox = final_bbox;
*wtile_bbox = final_bbox;
}
// Record this node in execution order (children before parents)
render_graph.record_node_for_execution(node_id);
}
// Generate filter commands for each tile (used for non-graph path rendering)
// Apply filter BEFORE clipping (per SVG spec: filter → clip → mask → opacity → blend)
for x in 0..self.width_tiles() {
for y in 0..self.height_tiles() {
self.get_mut(x, y).filter(layer.layer_id, filter.clone());
}
}
}
// Union this layer's bbox into the parent layer's bbox.
// This ensures the parent knows about all tiles used by this child layer,
// which is important for filter effects that may expand beyond the original content bounds.
if let Some(parent_layer) = self.layer_stack.last_mut() {
parent_layer.wtile_bbox.union(layer.wtile_bbox);
}
if layer.clip {
self.pop_clip();
}
let needs_buf = layer.needs_buf();
if needs_buf {
for x in 0..self.width_tiles() {
for y in 0..self.height_tiles() {
let t = self.get_mut(x, y);
if let Some(mask) = layer.mask.clone() {
t.mask(mask);
}
t.opacity(layer.opacity);
t.blend(layer.blend_mode);
t.pop_buf();
}
}
}
let in_clipped_filter_layer = layer.filter.is_some() && layer.clip;
// Decrement the depth counter after popping a filtered layer with clip
if in_clipped_filter_layer {
self.clipped_filter_layer_depth -= 1;
}
}
/// Adds a clipping region defined by the provided strips.
///
/// This method takes a vector of strips representing a clip path, calculates the
/// intersection with the current clip region, and updates the clip stack.
///
/// # Algorithm overview:
/// 1. Calculate bounding box of the clip path
/// 2. Intersect with current clip bounding box
/// 3. For each tile in the intersected bounding box:
/// - If covered by zero winding: `push_zero_clip`
/// - If fully covered by non-zero winding: do nothing (clip is a no-op)
/// - If partially covered: `push_clip`
pub fn push_clip(
&mut self,
strips: impl Into<Box<[Strip]>>,
layer_id: LayerId,
thread_idx: u8,
) {
let strips = strips.into();
let n_strips = strips.len();
// Calculate the bounding box of the clip path in strip coordinates
let path_bbox = if n_strips <= 1 {
WideTilesBbox::empty()
} else {
// Calculate the y range from first to last strip in wide tile coordinates
let wtile_y0 = strips[0].strip_y();
let wtile_y1 = strips[n_strips.saturating_sub(1)].strip_y() + 1;
// Calculate the x range by examining all strips in wide tile coordinates
let mut wtile_x0 = strips[0].x / WideTile::WIDTH;
let mut wtile_x1 = wtile_x0;
for i in 0..n_strips.saturating_sub(1) {
let strip = &strips[i];
let next_strip = &strips[i + 1];
let width =
((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16;
let x = strip.x;
wtile_x0 = wtile_x0.min(x / WideTile::WIDTH);
wtile_x1 = wtile_x1.max((x + width).div_ceil(WideTile::WIDTH));
}
WideTilesBbox::new([wtile_x0, wtile_y0, wtile_x1, wtile_y1])
};
let parent_bbox = self.active_bbox();
// Determine which tiles need clip processing:
// - For clipped filter layers: active_bbox() returns the full viewport, so parent_bbox
// already covers all tiles. We need to process all of them because the filter needs
// the entire layer rendered, and tiles outside the clip path must get `PushZeroClip`
// commands to properly suppress their content after filtering.
// - For normal clips: Intersect with the path bounds to only process tiles that are
// actually affected by the clip path, avoiding unnecessary work.
let clip_bbox = if self.clipped_filter_layer_depth > 0 {
// Use parent_bbox as-is (full viewport) to process all tiles
parent_bbox
} else {
// Optimize by processing only the intersection of parent and path bounds
parent_bbox.intersect(path_bbox)
};
let mut cur_wtile_x = clip_bbox.x0();
let mut cur_wtile_y = clip_bbox.y0();
// Process strips to determine the clipping state for each wide tile
for i in 0..n_strips.saturating_sub(1) {
let strip = &strips[i];
let strip_y = strip.strip_y();
// Skip strips before current wide tile row
if strip_y < cur_wtile_y {
continue;
}
// Process wide tiles in rows before this strip's row
// These wide tiles are all zero-winding (outside the path)
while cur_wtile_y < strip_y.min(clip_bbox.y1()) {
for wtile_x in cur_wtile_x..clip_bbox.x1() {
self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id);
}
// Reset x to the left edge of the clip bounding box
cur_wtile_x = clip_bbox.x0();
// Move to the next row
cur_wtile_y += 1;
}
// If we've reached the bottom of the clip bounding box, stop processing.
// Note that we are explicitly checking >= instead of ==, so that we abort if the clipping box
// is zero-area (see issue 1072).
if cur_wtile_y >= clip_bbox.y1() {
break;
}
// Process wide tiles to the left of this strip in the same row
let x = strip.x;
let wtile_x_clamped = (x / WideTile::WIDTH).min(clip_bbox.x1());
if cur_wtile_x < wtile_x_clamped {
// If winding is zero or doesn't match fill rule, these wide tiles are outside the path
let is_inside = strip.fill_gap();
if !is_inside {
for wtile_x in cur_wtile_x..wtile_x_clamped {
self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id);
}
}
// If winding is nonzero, then wide tiles covered entirely
// by sparse fill are no-op (no clipping is applied).
cur_wtile_x = wtile_x_clamped;
}
// Process wide tiles covered by the strip - these need actual clipping
let next_strip = &strips[i + 1];
let width =
((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16;
let wtile_x1 = (x + width).div_ceil(WideTile::WIDTH).min(clip_bbox.x1());
if cur_wtile_x < wtile_x1 {
for wtile_x in cur_wtile_x..wtile_x1 {
self.get_mut(wtile_x, cur_wtile_y).push_clip(layer_id);
}
cur_wtile_x = wtile_x1;
}
}
// Process any remaining wide tiles in the bounding box (all zero-winding)
while cur_wtile_y < clip_bbox.y1() {
for wtile_x in cur_wtile_x..clip_bbox.x1() {
self.get_mut(wtile_x, cur_wtile_y).push_zero_clip(layer_id);
}
cur_wtile_x = clip_bbox.x0();
cur_wtile_y += 1;
}
self.clip_stack.push(Clip {
clip_bbox,
strips,
thread_idx,
});
}
/// Get the bounding box of the current clip region or the entire viewport if no clip regions are active.
fn active_bbox(&self) -> WideTilesBbox {
// When in a clipped filter layer, use full viewport to allow
// filter to process the complete layer before applying clip as mask
if self.clipped_filter_layer_depth > 0 {
return self.full_viewport_bbox();
}
self.clip_stack
.last()
.map(|top| top.clip_bbox)
.unwrap_or_else(|| self.full_viewport_bbox())
}
/// Returns the bounding box covering the entire viewport in wide tile coordinates.
fn full_viewport_bbox(&self) -> WideTilesBbox {
WideTilesBbox::new([0, 0, self.width_tiles(), self.height_tiles()])
}
/// Removes the most recently added clip region.
///
/// This is the inverse operation of `push_clip`, carefully undoing all the clipping
/// operations while also handling any rendering needed for the clip region itself.
///
/// # Algorithm overview:
/// 1. Retrieve the top clip from the stack
/// 2. For each wide tile in the clip's bounding box:
/// - If covered by zero winding: `pop_zero_clip`
/// - If fully covered by non-zero winding: do nothing (was no-op)
/// - If partially covered: render the clip and `pop_clip`
///
/// This operation must be symmetric with `push_clip` to maintain a balanced clip stack.
fn pop_clip(&mut self) {
let Clip {
clip_bbox,
strips,
thread_idx,
} = self.clip_stack.pop().unwrap();
let n_strips = strips.len();
if n_strips == 0 {
return;
}
// Compute base alpha index and create shared clip attributes
let alpha_base_idx = strips[0].alpha_idx();
let clip_attrs_idx = self.attrs.clip.len() as u32;
self.attrs.clip.push(ClipAttrs {
thread_idx,
alpha_base_idx,
});
let mut cur_wtile_x = clip_bbox.x0();
let mut cur_wtile_y = clip_bbox.y0();
let mut pop_pending = false;
// Process each strip to determine the clipping state for each tile
for i in 0..n_strips.saturating_sub(1) {
let strip = &strips[i];
let strip_y = strip.strip_y();
// Skip strips before current tile row
if strip_y < cur_wtile_y {
continue;
}
// Process tiles in rows before this strip's row
// These tiles all had zero-winding clips
while cur_wtile_y < strip_y.min(clip_bbox.y1()) {
// Handle any pending clip pop from previous iteration
if core::mem::take(&mut pop_pending) {
self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip();
cur_wtile_x += 1;
}
// Pop zero clips for all remaining tiles in this row
for wtile_x in cur_wtile_x..clip_bbox.x1() {
self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip();
}
cur_wtile_x = clip_bbox.x0();
cur_wtile_y += 1;
}
// If we've reached the bottom of the clip bounding box, stop processing
// Note that we are explicitly checking >= instead of ==, so that we abort if the clipping box
// is zero-area (see issue 1072).
if cur_wtile_y >= clip_bbox.y1() {
break;
}
// Process tiles to the left of this strip in the same row
let x0 = strip.x;
let wtile_x_clamped = (x0 / WideTile::WIDTH).min(clip_bbox.x1());
if cur_wtile_x < wtile_x_clamped {
// Handle any pending clip pop from previous iteration
if core::mem::take(&mut pop_pending) {
self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip();
cur_wtile_x += 1;
}
// Pop zero clips for tiles that had zero winding or didn't match fill rule
// TODO: The winding check is probably not needed; if there was a fill,
// the logic below should have advanced wtile_x.
let is_inside = strip.fill_gap();
if !is_inside {
for wtile_x in cur_wtile_x..wtile_x_clamped {
self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip();
}
}
cur_wtile_x = wtile_x_clamped;
}
// Process tiles covered by the strip - render clip content and pop
let next_strip = &strips[i + 1];
let strip_width =
((next_strip.alpha_idx() - strip.alpha_idx()) / u32::from(Tile::HEIGHT)) as u16;
let mut clipped_x1 = x0 + strip_width;
let wtile_x0 = (x0 / WideTile::WIDTH).max(clip_bbox.x0());
let wtile_x1 = clipped_x1.div_ceil(WideTile::WIDTH).min(clip_bbox.x1());
// Calculate starting position and column for alpha mask
let mut x = x0;
let mut col = strip.alpha_idx() / u32::from(Tile::HEIGHT);
let clip_x = clip_bbox.x0() * WideTile::WIDTH;
if clip_x > x {
col += u32::from(clip_x - x);
x = clip_x;
clipped_x1 = clip_x.max(clipped_x1);
}
// Render clip strips for each affected tile and mark for popping
for wtile_x in wtile_x0..wtile_x1 {
// If we've moved past tile_x and have a pending pop, do it now
if cur_wtile_x < wtile_x && core::mem::take(&mut pop_pending) {
self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip();
}
// Calculate the portion of the strip that affects this tile
let x_rel = x % WideTile::WIDTH;
let width = clipped_x1.min((wtile_x + 1) * WideTile::WIDTH) - x;
// Create clip strip command for rendering the partial coverage
let cmd = CmdClipAlphaFill {
x: x_rel,
width,
alpha_offset: col * u32::from(Tile::HEIGHT) - alpha_base_idx,
attrs_idx: clip_attrs_idx,
};
x += width;
col += u32::from(width);
// Apply the clip strip command and update state
self.get_mut(wtile_x, cur_wtile_y).clip_strip(cmd);
cur_wtile_x = wtile_x;
// Only request a pop if the x coordinate is actually inside the bounds.
if cur_wtile_x < clip_bbox.x1() {
pop_pending = true;
}
}
// Handle fill regions between strips based on fill rule
let is_inside = next_strip.fill_gap();
if is_inside && strip_y == next_strip.strip_y() {
if cur_wtile_x >= clip_bbox.x1() {
continue;
}
let x2 = next_strip.x;
let clipped_x2 = x2.min((cur_wtile_x + 1) * WideTile::WIDTH);
let width = clipped_x2.saturating_sub(clipped_x1);
// If there's a gap, fill it. Only do this if the fill wouldn't cover the
// whole tile, as such clips are skipped by the `push_clip` function. See
// <https://github.com/linebender/vello/blob/de0659e4df9842c8857153841a2b4ba6f1020bb0/sparse_strips/vello_common/src/coarse.rs#L504-L516>
if width > 0 && width < WideTile::WIDTH {
let x_rel = clipped_x1 % WideTile::WIDTH;
self.get_mut(cur_wtile_x, cur_wtile_y)
.clip_fill(x_rel, width);
}
// If the next strip is a sentinel, skip the fill
// It's a sentinel in the row if there is non-zero winding for the sparse fill
// Look more into this in the strip.rs render function
if x2 == u16::MAX {
continue;
}
// If fill extends to next tile, pop current and handle next
if x2 > (cur_wtile_x + 1) * WideTile::WIDTH {
if core::mem::take(&mut pop_pending) {
self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip();
}
let width2 = x2 % WideTile::WIDTH;
cur_wtile_x = x2 / WideTile::WIDTH;
// If the strip is outside the clipping box, we don't need to do any
// filling, so we continue (also to prevent out-of-bounds access).
if cur_wtile_x >= clip_bbox.x1() {
continue;
}
if width2 > 0 {
// An important thing to note: Note that we are only applying
// `clip_fill` to the wide tile that is actually covered by the next
// strip, and not the ones in-between! For example, if the first strip
// is in wide tile 1 and the second in wide tile 4, we will do a clip
// fill in wide tile 1 and 4, but not in 2 and 3. The reason for this is
// that any tile in-between is fully covered and thus no clipping is
// necessary at all. See also the `push_clip` function, where we don't
// push a new buffer for such tiles.
self.get_mut(cur_wtile_x, cur_wtile_y).clip_fill(0, width2);
}
}
}
}
// Handle any pending clip pop from the last iteration
if core::mem::take(&mut pop_pending) {
self.get_mut(cur_wtile_x, cur_wtile_y).pop_clip();
cur_wtile_x += 1;
}
// Process any remaining tiles in the bounding box (all zero-winding)
while cur_wtile_y < clip_bbox.y1() {
for wtile_x in cur_wtile_x..clip_bbox.x1() {
self.get_mut(wtile_x, cur_wtile_y).pop_zero_clip();
}
cur_wtile_x = clip_bbox.x0();
cur_wtile_y += 1;
}
}
}
/// A wide tile.
#[derive(Debug)]
pub struct WideTile<const MODE: u8 = MODE_CPU> {
/// The x coordinate of the wide tile.
pub x: u16,
/// The y coordinate of the wide tile.
pub y: u16,
/// The background of the tile.
pub bg: PremulColor,
/// The draw commands of the tile.
pub cmds: Vec<Cmd>,
/// The number of zero-winding clips.
pub n_zero_clip: usize,
/// The number of non-zero-winding clips.
pub n_clip: usize,
/// The number of pushed buffers.
pub n_bufs: usize,
/// True when this tile is in a filtered layer with clipping applied.
/// When set, clip operations generate explicit commands instead of just
/// tracking counters, allowing filters to process clipped content correctly.
pub in_clipped_filter_layer: bool,
/// Maps layer Id to command ranges for this tile.
pub layer_cmd_ranges: HashMap<LayerId, LayerCommandRanges>,
/// Vector of layer IDs this tile participates in.
pub layer_ids: Vec<LayerKind>,
}
impl WideTile {
/// The width of a wide tile in pixels.
pub const WIDTH: u16 = 256;
/// The maximum coordinate of a wide tile.
pub const MAX_WIDE_TILE_COORD: u16 = u16::MAX / Self::WIDTH;
}
impl WideTile<MODE_CPU> {
/// Create a new wide tile.
pub fn new(width: u16, height: u16) -> Self {
Self::new_internal(width, height)
}
}
impl WideTile<MODE_HYBRID> {
/// Create a new wide tile.
pub fn new(width: u16, height: u16) -> Self {
Self::new_internal(width, height)
}
}
impl<const MODE: u8> WideTile<MODE> {
/// Create a new wide tile.
fn new_internal(x: u16, y: u16) -> Self {
let mut layer_cmd_ranges = HashMap::new();
layer_cmd_ranges.insert(0, LayerCommandRanges::default());
Self {
x,
y,
bg: PremulColor::from_alpha_color(TRANSPARENT),
cmds: vec![],
n_zero_clip: 0,
n_clip: 0,
n_bufs: 0,
in_clipped_filter_layer: false,
layer_cmd_ranges,
layer_ids: vec![LayerKind::Regular(0)],
}
}
/// Fill a rectangular region with a paint.
///
/// Generates fill commands unless the tile is in a zero-clip region (fully clipped out).
/// For clipped filter layers, commands are always generated since filters need the full
/// layer content rendered before applying the clip as a mask.
///
/// The `fill_hint` parameter is pre-computed by the caller based on paint type:
/// - `OpaqueSolid(color)`: Paint is an opaque solid color, can replace background
/// - `OpaqueImage`: Paint is an opaque image, can clear previous commands
/// - `None`: No optimization available
pub(crate) fn fill(
&mut self,
x: u16,
width: u16,
attrs_idx: u32,
current_layer_id: LayerId,
fill_hint: FillHint,
) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
match MODE {
MODE_CPU => {
// Check if we can apply overdraw elimination optimization.
// This requires filling the entire tile width with no clip/buffer stack.
//
// Note that we could be more aggressive in optimizing a whole-tile opaque fill
// even with a clip stack. It would be valid to elide all drawing commands from
// the enclosing clip push up to the fill. Further, we could extend the clip
// push command to include a background color, rather than always starting with
// a transparent buffer. Lastly, a sequence of push(bg); strip/fill; pop could
// be replaced with strip/fill with the color (the latter is true even with a
// non-opaque color).
//
// However, the extra cost of tracking such optimizations may outweigh the
// benefit, especially in hybrid mode with GPU painting.
let can_override =
x == 0 && width == WideTile::WIDTH && self.n_clip == 0 && self.n_bufs == 0;
if can_override {
match fill_hint {
FillHint::OpaqueSolid(color) => {
self.cmds.clear();
self.bg = color;
if let Some(ranges) =
self.layer_cmd_ranges.get_mut(&current_layer_id)
{
ranges.clear();
}
return;
}
FillHint::OpaqueImage => {
// Opaque image: clear previous commands but still emit the fill.
self.cmds.clear();
self.bg = PremulColor::from_alpha_color(TRANSPARENT);
if let Some(ranges) =
self.layer_cmd_ranges.get_mut(&current_layer_id)
{
ranges.clear();
}
// Fall through to emit the fill command below, as opposed to
// solid paints where we have a return statement.
}
FillHint::None => {}
}
}
self.record_fill_cmd(current_layer_id, self.cmds.len());
self.cmds.push(Cmd::Fill(CmdFill {
x,
width,
attrs_idx,
}));
}
MODE_HYBRID => {
self.record_fill_cmd(current_layer_id, self.cmds.len());
self.cmds.push(Cmd::Fill(CmdFill {
x,
width,
attrs_idx,
}));
}
_ => unreachable!(),
}
}
}
/// Fill a region using an alpha mask from a strip.
///
/// Generates alpha fill commands unless the tile is in a zero-clip region (fully clipped out).
/// For clipped filter layers, commands are always generated since filters need the full
/// layer content rendered before applying the clip as a mask.
pub(crate) fn strip(&mut self, cmd_strip: CmdAlphaFill, current_layer_id: LayerId) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
self.record_fill_cmd(current_layer_id, self.cmds.len());
self.cmds.push(Cmd::AlphaFill(cmd_strip));
}
}
/// Adds a new clip region to the current wide tile.
///
/// Pushes a clip buffer unless the tile is in a zero-clip region (fully clipped out).
/// For clipped filter layers, clip buffers are always pushed since filters need explicit
/// clip state to process the full layer before applying the clip as a mask.
pub fn push_clip(&mut self, layer_id: LayerId) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
self.push_buf(LayerKind::Clip(layer_id));
self.n_clip += 1;
}
}
/// Removes the most recently added clip region from the current wide tile.
///
/// Pops a clip buffer unless the tile is in a zero-clip region (fully clipped out).
/// For clipped filter layers, clip buffers are always popped since filters need explicit
/// clip state to process the full layer before applying the clip as a mask.
pub fn pop_clip(&mut self) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
self.pop_buf();
self.n_clip -= 1;
}
}
/// Adds a zero-winding clip region to the stack.
///
/// Zero-winding clips represent tiles completely outside the clip path.
/// Normally these just increment a counter to suppress drawing, but for
/// clipped filter layers we generate explicit commands so filters can
/// process the entire layer before applying the clip as a mask.
pub fn push_zero_clip(&mut self, layer_id: LayerId) {
if self.in_clipped_filter_layer {
// Generate explicit command for filter processing
self.cmds.push(Cmd::PushZeroClip(layer_id));
}
self.n_zero_clip += 1;
}
/// Removes the most recently added zero-winding clip region.
pub fn pop_zero_clip(&mut self) {
if self.in_clipped_filter_layer {
// Generate explicit command for filter processing
self.cmds.push(Cmd::PopZeroClip);
}
self.n_zero_clip -= 1;
}
/// Checks if the current clip region is a zero-winding clip.
pub fn is_zero_clip(&mut self) -> bool {
self.n_zero_clip > 0
}
/// Applies a clip strip operation with the given parameters.
///
/// Note: Unlike content operations (`strip`, `push_clip`, etc.), clip operations don't need
/// the `|| self.in_clipped_filter_layer` check. Filter effects need full layer *content*
/// rendered (even in zero-clip areas).
pub fn clip_strip(&mut self, cmd_clip_strip: CmdClipAlphaFill) {
if (!self.is_zero_clip()) && !matches!(self.cmds.last(), Some(Cmd::PushBuf(_))) {
self.cmds.push(Cmd::ClipStrip(cmd_clip_strip));
}
}
/// Applies a clip fill operation at the specified position and width.
pub fn clip_fill(&mut self, x: u16, width: u16) {
if (!self.is_zero_clip()) && !matches!(self.cmds.last(), Some(Cmd::PushBuf(_))) {
self.cmds.push(Cmd::ClipFill(CmdClipFill { x, width }));
}
}
/// Records the fill command for a specific layer.
pub fn record_fill_cmd(&mut self, layer_id: LayerId, cmd_idx: usize) {
self.layer_cmd_ranges.entry(layer_id).and_modify(|ranges| {
ranges.full_range.end = cmd_idx + 1;
if ranges.render_range.is_empty() {
ranges.render_range = cmd_idx..cmd_idx + 1;
} else {
ranges.render_range.end = cmd_idx + 1;
}
});
}
/// Push a buffer for a new layer.
///
/// Different layer kinds are handled differently:
/// - Regular layers: Use local `blend_buf` stack for temporary storage
/// - Filtered layers: Materialized in persistent layer storage for filter processing
/// - Clip layers: Special handling for clipping operations
pub fn push_buf(&mut self, layer_kind: LayerKind) {
let top_layer = layer_kind.id();
if matches!(layer_kind, LayerKind::Filtered(_)) {
self.layer_cmd_ranges.insert(
top_layer,
LayerCommandRanges {
full_range: self.cmds.len()..self.cmds.len() + 1,
// Start with empty render_range; will be updated by `record_fill_cmd` and `pop_buf`.
render_range: self.cmds.len() + 1..self.cmds.len() + 1,
},
);
} else if matches!(layer_kind, LayerKind::Clip(_)) {
self.layer_cmd_ranges.entry(top_layer).and_modify(|ranges| {
ranges.full_range.end = self.cmds.len() + 1;
// Start with empty render_range; will be updated by `record_fill_cmd` and `pop_buf`.
ranges.render_range = self.cmds.len() + 1..self.cmds.len() + 1;
});
}
self.cmds.push(Cmd::PushBuf(layer_kind));
self.layer_ids.push(layer_kind);
self.n_bufs += 1;
}
/// Pop the most recent buffer.
pub fn pop_buf(&mut self) {
let top_layer = self.layer_ids.pop().unwrap();
let mut next_layer = *self.layer_ids.last().unwrap();
if matches!(self.cmds.last(), Some(&Cmd::PushBuf(_))) {
// Optimization: If no drawing happened between the last `PushBuf`,
// we can just pop it instead.
self.cmds.pop();
} else {
self.layer_cmd_ranges
.entry(top_layer.id())
.and_modify(|ranges| {
ranges.full_range.end = self.cmds.len() + 1;
});
if top_layer.id() == next_layer.id() {
next_layer = *self
.layer_ids
.get(self.layer_ids.len().saturating_sub(2))
.unwrap();
}
self.layer_cmd_ranges
.entry(next_layer.id())
.and_modify(|ranges| {
ranges.full_range.end = self.cmds.len() + 1;
ranges.render_range.end = self.cmds.len() + 1;
});
self.cmds.push(Cmd::PopBuf);
}
self.n_bufs -= 1;
}
/// Apply an opacity to the whole buffer.
pub fn opacity(&mut self, opacity: f32) {
if opacity != 1.0 {
self.cmds.push(Cmd::Opacity(opacity));
}
}
/// Apply a filter effect to the whole buffer.
pub fn filter(&mut self, layer_id: LayerId, filter: Filter) {
self.cmds.push(Cmd::Filter(layer_id, filter));
}
/// Apply a mask to the whole buffer.
pub fn mask(&mut self, mask: Mask) {
self.cmds.push(Cmd::Mask(mask));
}
/// Blend the current buffer into the previous buffer in the stack.
pub fn blend(&mut self, blend_mode: BlendMode) {
// Optimization: If no drawing happened since the last `PushBuf` and the blend mode
// is not destructive, we do not need to do any blending at all.
if !matches!(self.cmds.last(), Some(&Cmd::PushBuf(_))) || blend_mode.is_destructive() {
self.cmds.push(Cmd::Blend(blend_mode));
}
}
}
/// Debug utilities for wide tiles.
///
/// These methods are only available in debug builds (`debug_assertions`).
/// They provide introspection into the command buffer for debugging and logging purposes.
#[cfg(debug_assertions)]
impl<const MODE: u8> WideTile<MODE> {
/// Lists all commands in this wide tile with their indices and names.
///
/// Returns a formatted string with each command on a new line, showing its index
/// and human-readable name. This is useful for debugging and understanding the
/// command sequence.
///
/// # Example
///
/// ```ignore
/// let commands = wide_tile.list_commands();
/// println!("{}", commands);
/// // Output:
/// // 0: PushBuf(Regular)
/// // 1: FillPath
/// // 2: PushZeroClip
/// // 3: FillPath
/// // 4: PopBuf
/// ```
#[allow(dead_code, reason = "useful for debugging")]
pub fn list_commands(&self) -> String {
self.cmds
.iter()
.enumerate()
.map(|(i, cmd)| format!("{}: {}", i, cmd.name()))
.collect::<Vec<_>>()
.join("\n")
}
}
/// Optimization hint for fill operations, computed in `Wide::generate` and passed to `WideTile::fill`.
///
/// This enum communicates whether a fill operation can benefit from overdraw elimination:
/// - For opaque solid colors: we can set the background color directly and skip the fill
/// - For opaque images: we can clear previous commands but still need to emit the fill
#[derive(Debug, Clone, Copy)]
pub enum FillHint {
/// No optimization possible, emit fill command normally.
None,
/// Paint is an opaque solid color - can replace background if conditions are met.
OpaqueSolid(PremulColor),
/// Paint is an opaque image - can clear previous commands if conditions are met.
OpaqueImage,
}
/// Distinguishes between different types of layers and their storage strategies.
///
/// Each layer kind determines how the layer's content is stored and processed:
/// - Regular layers are blended on-the-fly using a temporary buffer stack
/// - Filtered layers are materialized in persistent storage for filter processing
/// - Clip layers are special buffers used for clipping operations
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayerKind {
/// Regular layer using local `blend_buf` stack for temporary storage.
Regular(LayerId),
/// Filtered layer materialized in persistent `layer_manager` storage.
Filtered(LayerId),
/// Clip layer for clipping operations.
Clip(LayerId),
}
impl LayerKind {
/// Get the underlying layer ID.
///
/// All layer kinds contain a layer ID that uniquely identifies the layer.
pub fn id(&self) -> LayerId {
match self {
Self::Regular(id) | Self::Filtered(id) | Self::Clip(id) => *id,
}
}
}
/// A drawing command for wide tiles.
///
/// Commands are executed in order to render the final image. They include
/// drawing operations (`Fill`, `AlphaFill`), layer management (`PushBuf`, `PopBuf`),
/// clipping operations (`ClipFill`, `ClipStrip`), and post-processing effects
/// (`Filter`, `Blend`, `Opacity`, `Mask`).
#[derive(Debug, PartialEq)]
pub enum Cmd {
/// Fill a rectangular region with a solid color or paint.
Fill(CmdFill),
/// Fill a region with a paint, modulated by an alpha mask.
AlphaFill(CmdAlphaFill),
/// Pushes a new buffer for drawing.
/// Regular layers use the local `blend_buf` stack.
/// Filtered layers are materialized in persistent layer storage.
PushBuf(LayerKind),
/// Pops the most recent buffer and blends it into the previous buffer.
PopBuf,
/// A fill command within a clipping region.
///
/// This command will blend the contents of the current buffer within the clip fill region
/// into the previous buffer in the stack.
ClipFill(CmdClipFill),
/// A fill command with alpha mask within a clipping region.
///
/// This command will blend the contents of the current buffer within the clip fill region
/// into the previous buffer in the stack, with an additional alpha mask.
ClipStrip(CmdClipAlphaFill),
/// Marks entry into a zero-winding clip region for a clipped filter layer.
///
/// Zero-winding clips represent tiles completely outside the clip path. For clipped
/// filter layers, this command allows the filter to process the full layer content
/// before applying the clip as a mask (per SVG spec: filter → clip → mask → blend).
PushZeroClip(LayerId),
/// Marks exit from a zero-winding clip region for a clipped filter layer.
PopZeroClip,
/// Apply a filter effect to a layer's contents.
///
/// This command applies a filter (e.g., blur, drop shadow) to the specified layer's
/// rendered content. Per the SVG specification, filters are applied before clipping,
/// masking, blending, and opacity operations.
Filter(LayerId, Filter),
/// Blend the current buffer into the previous buffer.
///
/// This command blends the contents of the current buffer into the previous buffer
/// using the specified blend mode (e.g., multiply, screen, overlay).
Blend(BlendMode),
/// Apply uniform opacity to the current buffer.
///
/// Multiplies the alpha channel of all pixels in the buffer by the given opacity value.
Opacity(f32),
/// Apply a mask to the current buffer.
///
/// Modulates the alpha channel of the buffer using the provided mask.
Mask(Mask),
}
#[cfg(debug_assertions)]
impl Cmd {
/// Returns a human-readable name for this command.
///
/// This is useful for debugging, logging, and displaying command information
/// in a user-friendly format. To get detailed paint information, use `name_with_attrs`
/// which can look up the paint from the command attributes.
///
/// **Note:** This method is only available in debug builds (`debug_assertions`).
pub fn name(&self) -> &'static str {
match self {
Self::Fill(_) => "FillPath",
Self::AlphaFill(_) => "AlphaFillPath",
Self::PushBuf(layer_kind) => match layer_kind {
LayerKind::Regular(_) => "PushBuf(Regular)",
LayerKind::Filtered(_) => "PushBuf(Filtered)",
LayerKind::Clip(_) => "PushBuf(Clip)",
},
Self::PopBuf => "PopBuf",
Self::ClipFill(_) => "ClipPathFill",
Self::ClipStrip(_) => "ClipPathStrip",
Self::PushZeroClip(_) => "PushZeroClip",
Self::PopZeroClip => "PopZeroClip",
Self::Filter(_, _) => "Filter",
Self::Blend(_) => "Blend",
Self::Opacity(_) => "Opacity",
Self::Mask(_) => "Mask",
}
}
/// Returns a human-readable name for this command with detailed paint information.
///
/// This variant looks up paint details from the command attributes for fill commands.
///
/// **Note:** This method is only available in debug builds (`debug_assertions`).
pub fn name_with_attrs(
&self,
fill_attrs: &[FillAttrs],
encoded_paints: &[EncodedPaint],
) -> String {
match self {
Self::Fill(cmd) => {
if let Some(attrs) = fill_attrs.get(cmd.attrs_idx as usize) {
format!("FillPath({})", paint_name(&attrs.paint, encoded_paints))
} else {
format!("FillPath(attrs_idx={})", cmd.attrs_idx)
}
}
Self::AlphaFill(cmd) => {
if let Some(attrs) = fill_attrs.get(cmd.attrs_idx as usize) {
format!(
"AlphaFillPath({})",
paint_name(&attrs.paint, encoded_paints)
)
} else {
format!("AlphaFillPath(attrs_idx={})", cmd.attrs_idx)
}
}
_ => self.name().into(),
}
}
}
/// Returns a human-readable description of a paint.
#[cfg(debug_assertions)]
fn paint_name(paint: &Paint, encoded_paints: &[EncodedPaint]) -> String {
match paint {
Paint::Solid(color) => {
let rgba = color.as_premul_rgba8();
format!(
"Solid(#{:02x}{:02x}{:02x}{:02x})",
rgba.r, rgba.g, rgba.b, rgba.a
)
}
Paint::Indexed(idx) => {
let index = idx.index();
if let Some(encoded) = encoded_paints.get(index) {
let kind = match encoded {
EncodedPaint::Gradient(g) => match &g.kind {
crate::encode::EncodedKind::Linear(_) => "LinearGradient",
crate::encode::EncodedKind::Radial(_) => "RadialGradient",
crate::encode::EncodedKind::Sweep(_) => "SweepGradient",
},
EncodedPaint::Image(_) => "Image",
EncodedPaint::BlurredRoundedRect(_) => "BlurredRoundedRect",
};
format!("{}[{}]", kind, index)
} else {
format!("Indexed({})", index)
}
}
}
}
/// Shared attributes for alpha fill commands.
#[derive(Debug, Clone, PartialEq)]
pub struct FillAttrs {
/// The index of the thread that owns the alpha buffer
/// containing the mask values at `alpha_idx`.
/// Always 0 in single-threaded mode.
pub thread_idx: u8,
/// The paint (color, gradient, etc.) to fill the region with.
// TODO: Store premultiplied colors as indexed paints as well, to reduce
// memory overhead? Or get rid of indexed paints and inline all paints?
pub paint: Paint,
/// The blend mode to apply before drawing the contents.
pub blend_mode: BlendMode,
/// A mask to apply to the command.
pub mask: Option<Mask>,
/// Base index into the alpha buffer for this path's commands.
/// Commands store a relative offset that is added to this base.
alpha_base_idx: u32,
}
impl FillAttrs {
/// Compute the absolute alpha buffer index from a relative offset.
pub fn alpha_idx(&self, offset: u32) -> u32 {
self.alpha_base_idx + offset
}
}
/// Shared attributes for clip alpha fill commands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipAttrs {
/// The index of the thread that owns the alpha buffer
/// containing the mask values at `alpha_idx`.
/// Always 0 in single-threaded mode.
pub thread_idx: u8,
/// Base index into the alpha buffer for this clip path's commands.
/// Commands store a relative offset that is added to this base.
alpha_base_idx: u32,
}
impl ClipAttrs {
/// Compute the absolute alpha buffer index from a relative offset.
pub fn alpha_idx(&self, offset: u32) -> u32 {
self.alpha_base_idx + offset
}
}
/// Container for shared command attributes.
///
/// This struct holds the shared attributes for fill and clip commands,
/// allowing them to be passed together to functions that need both.
#[derive(Debug, Default, Clone)]
pub struct CommandAttrs {
/// Shared attributes for fill commands, indexed by `attrs_idx` in `CmdFill`/`CmdAlphaFill`.
pub fill: Vec<FillAttrs>,
/// Shared attributes for clip commands, indexed by `attrs_idx` in `CmdClipAlphaFill`.
pub clip: Vec<ClipAttrs>,
}
impl CommandAttrs {
/// Clear all attributes.
pub fn clear(&mut self) {
self.fill.clear();
self.clip.clear();
}
}
/// Fill a consecutive horizontal region of a wide tile.
///
/// This command fills a rectangular region with the specified paint.
/// The region starts at x-coordinate `x` and extends for `width` pixels
/// horizontally, spanning the full height of the wide tile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CmdFill {
/// The horizontal start position relative to the wide tile's left edge, in pixels.
pub x: u16,
/// The width of the filled region in pixels.
pub width: u16,
/// Index into the command attributes array.
pub attrs_idx: u32,
}
/// Fill a consecutive horizontal region with an alpha mask.
///
/// Similar to `CmdFill`, but modulates the paint by an alpha mask stored
/// in a separate buffer. This is used for anti-aliased edges and partial
/// coverage from path rasterization.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CmdAlphaFill {
/// The horizontal start position relative to the wide tile's left edge, in pixels.
pub x: u16,
/// The width of the filled region in pixels.
pub width: u16,
/// Relative offset to the alpha buffer location.
/// Use `FillAttrs::alpha_idx(alpha_offset)` to compute the absolute index.
pub alpha_offset: u32,
/// Index into the command attributes array.
pub attrs_idx: u32,
}
/// Fill operation within a clipping region.
///
/// This command copies a horizontal region from the top of the clip buffer stack
/// to the next buffer on the stack, effectively rendering the clipped content.
/// Unlike `CmdFill`, this doesn't fill with a paint but transfers existing content.
#[derive(Debug, PartialEq, Eq)]
pub struct CmdClipFill {
/// The horizontal start position relative to the wide tile's left edge, in pixels.
pub x: u16,
/// The width of the region to copy in pixels.
pub width: u16,
}
/// Alpha-masked fill operation within a clipping region.
///
/// This command composites a horizontal region from the top of the clip buffer stack
/// to the next buffer, modulated by an alpha mask. This is used for anti-aliased
/// clip edges.
#[derive(Debug, PartialEq, Eq)]
pub struct CmdClipAlphaFill {
/// The horizontal start position relative to the wide tile's left edge, in pixels.
pub x: u16,
/// The width of the region to composite in pixels.
pub width: u16,
/// Relative offset to the alpha buffer location.
/// Use `ClipAttrs::alpha_idx(alpha_offset)` to compute the absolute index.
pub alpha_offset: u32,
/// Index into the clip attributes array.
pub attrs_idx: u32,
}
trait BlendModeExt {
/// Whether a blend mode might cause destructive changes in the backdrop.
/// This disallows certain optimizations (like for example inlining a blend mode
/// or only applying a blend mode to the current clipping area).
fn is_destructive(&self) -> bool;
}
impl BlendModeExt for BlendMode {
fn is_destructive(&self) -> bool {
matches!(
self.compose,
Compose::Clear
| Compose::Copy
| Compose::SrcIn
| Compose::DestIn
| Compose::SrcOut
| Compose::DestAtop
)
}
}
/// Ranges of commands for a specific layer in a specific tile.
///
/// This structure tracks two different ranges of commands:
/// - The full range includes all layer operations (push, draw, pop)
/// - The render range includes only the actual drawing commands
#[derive(Debug, Clone, Default)]
pub struct LayerCommandRanges {
/// Full range including `PushBuf`, all commands, and `PopBuf`.
pub full_range: Range<usize>,
/// Range containing only fill commands (`Fill`, `AlphaFill`).
/// This is the range to replace when sampling from a filtered layer.
pub render_range: Range<usize>,
}
impl LayerCommandRanges {
/// Clear the full range and render range.
#[inline]
pub fn clear(&mut self) {
self.full_range = 0..0;
self.render_range = 0..0;
}
}
#[cfg(test)]
mod tests {
use crate::coarse::{FillHint, LayerKind, MODE_CPU, Wide, WideTile};
use crate::kurbo::Affine;
use crate::peniko::{BlendMode, Compose, Mix};
use crate::render_graph::RenderGraph;
use crate::strip::Strip;
use alloc::{boxed::Box, vec};
#[test]
fn optimize_empty_layers() {
let mut wide = WideTile::<MODE_CPU>::new(0, 0);
wide.push_buf(LayerKind::Regular(0));
wide.pop_buf();
assert!(wide.cmds.is_empty());
}
#[test]
fn basic_layer() {
let mut wide = WideTile::<MODE_CPU>::new(0, 0);
wide.push_buf(LayerKind::Regular(0));
wide.fill(0, 10, 0, 0, FillHint::None);
wide.fill(10, 10, 0, 0, FillHint::None);
wide.pop_buf();
assert_eq!(wide.cmds.len(), 4);
}
#[test]
fn dont_inline_blend_with_two_fills() {
let blend_mode = BlendMode::new(Mix::Lighten, Compose::SrcOver);
let mut wide = WideTile::<MODE_CPU>::new(0, 0);
wide.push_buf(LayerKind::Regular(0));
wide.fill(0, 10, 0, 0, FillHint::None);
wide.fill(10, 10, 0, 0, FillHint::None);
wide.blend(blend_mode);
wide.pop_buf();
assert_eq!(wide.cmds.len(), 5);
}
#[test]
fn dont_inline_destructive_blend() {
let blend_mode = BlendMode::new(Mix::Lighten, Compose::Clear);
let mut wide = WideTile::<MODE_CPU>::new(0, 0);
wide.push_buf(LayerKind::Regular(0));
wide.fill(0, 10, 0, 0, FillHint::None);
wide.blend(blend_mode);
wide.pop_buf();
assert_eq!(wide.cmds.len(), 4);
}
#[test]
fn tile_coordinates() {
let wide = Wide::<MODE_CPU>::new(1000, 258);
let tile_1 = wide.get(1, 3);
assert_eq!(tile_1.x, 256);
assert_eq!(tile_1.y, 12);
let tile_2 = wide.get(2, 15);
assert_eq!(tile_2.x, 512);
assert_eq!(tile_2.y, 60);
}
#[test]
fn reset_clears_layer_and_clip_stacks() {
type ClipPath = Option<Box<[Strip]>>;
let mut wide = Wide::<MODE_CPU>::new(1000, 258);
let mut render_graph = RenderGraph::new();
let no_clip_path: ClipPath = None;
wide.push_layer(
1,
no_clip_path,
BlendMode::default(),
None,
0.5,
None,
Affine::IDENTITY,
&mut render_graph,
0,
);
assert_eq!(wide.layer_stack.len(), 1);
assert_eq!(wide.clip_stack.len(), 0);
let strip = Strip::new(2, 2, 0, true);
let clip_path = Some(vec![strip].into_boxed_slice());
wide.push_layer(
2,
clip_path,
BlendMode::default(),
None,
0.09,
None,
Affine::IDENTITY,
&mut render_graph,
0,
);
assert_eq!(wide.layer_stack.len(), 2);
assert_eq!(wide.clip_stack.len(), 1);
wide.reset();
assert_eq!(wide.layer_stack.len(), 0);
assert_eq!(wide.clip_stack.len(), 0);
}
}