blob: e8ab614026c505dbebe8b0709d116233150dc9ba [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::geometry::RectU16;
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;
/// The index that in `push_buf_indices` is reserved for indicating the target is the final
/// destination surface.
const TARGET_SURFACE_PUSH_BUF_IDX: usize = usize::MAX;
#[derive(Debug)]
struct LayerKindAndOccupiedTiles {
/// The kind of layer needing a scratch buffer. This is tracked to allow lazy buffer pushing to
/// know for which layer kind to push.
kind: LayerKind,
/// The indices into [`Wide::tiles`] of wide tiles that occupy this layer. These are the wide
/// tiles that have pushed a buffer and need to be popped when the layer is popped.
occupied_tiles: Vec<usize>,
}
/// For layers that require allocating a new scratch buffer for drawing its contents, this stores
/// the layer kind and the occupied wide tiles.
///
/// The wide tile push buffer command [`WideTile::push_buf`] is performed lazily upon actually
/// being drawn into.
///
/// See [`LayerKindAndOccupiedTiles`].
///
/// This is a small wrapper around [`Vec`] so we can keep the
/// [`LayerKindAndOccupiedTiles::occupied_tiles`] allocations around.
#[derive(Debug, Default)]
struct NeedsBufLayerStack {
stack: Vec<LayerKindAndOccupiedTiles>,
/// The actual length of [`Self::stack`], as we never actually shrink it (see the note above
/// about keeping allocations around).
len: usize,
}
impl NeedsBufLayerStack {
#[inline]
fn clear(&mut self) {
self.len = 0;
}
#[inline]
fn push(&mut self, kind: LayerKind) {
if self.len == self.stack.len() {
self.stack.push(LayerKindAndOccupiedTiles {
kind,
occupied_tiles: vec![],
});
} else {
self.stack[self.len].occupied_tiles.clear();
self.stack[self.len].kind = kind;
}
self.len += 1;
}
#[inline]
fn pop(&mut self) {
debug_assert!(self.len > 0, "Called `pop` at the root");
self.len -= 1;
}
#[inline]
fn last(&self) -> Option<&LayerKindAndOccupiedTiles> {
self.len.checked_sub(1).map(|idx| &self.stack[idx])
}
}
/// A container for wide tiles.
#[derive(Debug)]
pub struct Wide<const MODE: u8 = MODE_CPU> {
/// The width of the container.
width: u16,
/// The height of the container.
height: u16,
/// The wide tiles in the container.
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 layer kinds and occupied tiles for layers that require buffers.
///
/// This contains exactly one entry for each layer with [`Layer::needs_buf`] in
/// [`Self::layer_stack`].
layers_needing_buf_stack: NeedsBufLayerStack,
/// 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,
/// Global batch counter, incremented each time we transition from fast strip rendering
/// to a set of strips passing through coarse rasterization.
batch_count: u32,
/// Whether to enable the optimization that allows fills with an opaque color to clear
/// all previous commands if it spans the whole wide tile.
///
/// This needs to be disabled for the interleaved rendering path in `vello_hybrid`, because
/// the background is applied as the very first operation, and we have no way of clearing
/// strips in the fast path that would affect the area of the wide tile.
enable_bg_optimization: bool,
/// Whether at least one of the wide tiles has been mutated and thus they
/// need to be reset.
tiles_dirty: bool,
}
/// 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 in wide tile coordinates.
///
/// This is a wrapper around [`RectU16`], adding some utilities useful for converting between
/// wide-tile space and pixel-space. `(x0, y0)` is the top-left corner and `(x1, y1)` is the
/// bottom-right corner, both in wide tile units.
#[derive(Debug, Clone, Copy)]
pub struct WideTilesBbox {
/// The bounding box.
bbox: RectU16,
}
impl WideTilesBbox {
/// A empty rectangle with all coordinates set to zero.
pub const ZERO: Self = Self {
bbox: RectU16::ZERO,
};
/// An empty, maximally inverted rectangle, useful as a starting value for incremental union
/// operations.
///
/// Has `(x0, y0) = (u16::MAX, u16::MAX)` and `(x1, y1) = (0, 0)`.
const INVERTED: Self = Self {
bbox: RectU16::INVERTED,
};
/// Create a new bounding box from wide tile coordinates.
pub fn new(x0: u16, y0: u16, x1: u16, y1: u16) -> Self {
Self {
bbox: RectU16::new(x0, y0, x1, y1),
}
}
/// Get the x0 coordinate of the bounding box.
#[inline(always)]
pub fn x0(&self) -> u16 {
self.bbox.x0
}
/// Get the y0 coordinate of the bounding box.
#[inline(always)]
pub fn y0(&self) -> u16 {
self.bbox.y0
}
/// Get the x1 coordinate of the bounding box.
#[inline(always)]
pub fn x1(&self) -> u16 {
self.bbox.x1
}
/// Get the y1 coordinate of the bounding box.
#[inline(always)]
pub fn y1(&self) -> u16 {
self.bbox.y1
}
/// Returns `true` if the bounding box has zero area.
#[inline(always)]
pub fn is_empty(&self) -> bool {
self.bbox.is_empty()
}
/// Get the width of the bounding box in tiles (x1 - x0).
#[inline(always)]
pub fn width_tiles(&self) -> u16 {
self.bbox.width()
}
/// 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 in tiles (y1 - y0).
#[inline(always)]
pub fn height_tiles(&self) -> u16 {
self.bbox.height()
}
/// 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 {
self.bbox.contains(x, y)
}
/// Calculate the intersection of this bounding box with another.
#[inline(always)]
pub(crate) fn intersect(self, other: Self) -> Self {
Self {
bbox: self.bbox.intersect(other.bbox),
}
}
/// Update this bounding box to include another bounding box (union in place).
#[inline(always)]
pub(crate) fn union(&mut self, other: Self) {
self.bbox.union(other.bbox);
}
/// 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.x0 = self.bbox.x0.min(wtile_x);
self.bbox.y0 = self.bbox.y0.min(wtile_y);
self.bbox.x1 = self.bbox.x1.max(wtile_x + 1);
self.bbox.y1 = self.bbox.y1.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, true)
}
}
impl Wide<MODE_HYBRID> {
/// Create a new container for wide tiles.
pub fn new(width: u16, height: u16, enable_bg_optimization: bool) -> Self {
Self::new_internal(width, height, enable_bg_optimization)
}
/// Record a coarse batch boundary.
///
/// Each tile lazily emits the pending `BatchEnd` markers the next
/// time it receives a command.
#[inline(always)]
pub fn end_batch(&mut self) {
self.batch_count += 1;
}
}
impl<const MODE: u8> Wide<MODE> {
/// Create a new container for wide tiles.
fn new_internal(width: u16, height: u16, enable_bg_optimization: bool) -> 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(),
enable_bg_optimization,
layer_stack: vec![],
clip_stack: vec![],
// Start with root node 0.
filter_node_stack: vec![0],
clipped_filter_layer_depth: 0,
layers_needing_buf_stack: NeedsBufLayerStack::default(),
batch_count: 0,
tiles_dirty: false,
}
}
/// 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) {
if self.tiles_dirty {
for tile in &mut self.tiles {
tile.reset();
}
self.tiles_dirty = false;
}
self.attrs.clear();
self.layer_stack.clear();
self.layers_needing_buf_stack.clear();
self.clip_stack.clear();
self.filter_node_stack.truncate(1);
self.clipped_filter_layer_depth = 0;
self.batch_count = 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 index of the wide tile at the given coordinates.
///
/// Panics if the coordinates are out-of-range.
#[inline]
pub fn get_idx(&self, x: u16, y: u16) -> usize {
assert!(
x < self.width_tiles() && y < self.height_tiles(),
"attempted to access out-of-bounds wide tile"
);
usize::from(y) * usize::from(self.width_tiles()) + usize::from(x)
}
/// Get the index of the wide tile at the given coordinates.
///
/// Panics if the coordinates are out-of-range.
pub fn get(&self, x: u16, y: u16) -> &WideTile<MODE> {
let idx = self.get_idx(x, y);
&self.tiles[idx]
}
/// Get mutable access to the wide tile at the given coordinates.
///
/// Panics if the coordinates are out-of-range.
fn get_mut(&mut self, x: u16, y: u16) -> &mut WideTile<MODE> {
let idx = self.get_idx(x, y);
&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;
}
self.tiles_dirty = true;
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 and batch_count to avoid borrowing issues
let current_layer_id = self.get_current_layer_id();
let batch_count = self.batch_count;
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);
let idx = self.get_idx(wtile_x, strip_y);
self.tiles[idx].strip(
batch_count,
idx,
&mut self.layers_needing_buf_stack,
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() && self.enable_bg_optimization {
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.may_have_opacities
&& img.sampler.alpha == 1.0
&& img.tint.is_none_or(|t| t.color.components[3] >= 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;
let idx = self.get_idx(wtile_x, strip_y);
self.tiles[idx].fill(
batch_count,
idx,
&mut self.layers_needing_buf_stack,
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,
) {
self.tiles_dirty = true;
// 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() {
let batch_count = self.batch_count;
self.layers_needing_buf_stack.push(layer_kind);
// We eagerly push buffers for the current active bbox if this is a destructive blend
// or the entire viewport if we are (nested in) a clipped filter layer.
//
// TODO: We may be able to do away with some or all of this eager pushing in the
// future, but for now these types of layers need buffers for all wide tiles regardless
// of whether they're drawn on.
if layer.blend_mode.is_destructive() || self.clipped_filter_layer_depth > 0 {
let active_bbox = if self.clipped_filter_layer_depth > 0 {
self.full_viewport_bbox()
} else {
self.active_bbox()
};
for y in active_bbox.y0()..active_bbox.y1() {
for x in active_bbox.x0()..active_bbox.x1() {
let idx = self.get_idx(x, y);
self.tiles[idx].ensure_layer_stack_bufs(
idx,
&mut self.layers_needing_buf_stack,
batch_count,
);
// Mark tiles that are in a clipped filter layer so they generate
// explicit clip commands for proper filter processing.
self.tiles[idx].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) {
self.tiles_dirty = true;
// This method basically unwinds everything we did in `push_layer`.
let mut layer = self.layer_stack.pop().unwrap();
let batch_count = self.batch_count;
if let Some(filter) = &layer.filter {
let mut final_bbox = WideTilesBbox::INVERTED;
// 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();
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)
// Also ensure that each wide tile in the filter bbox (out of which some might not
// have been drawn on) has a `PushBuf` command.
for x in final_bbox.x0()..final_bbox.x1() {
for y in final_bbox.y0()..final_bbox.y1() {
let idx = self.get_idx(x, y);
self.tiles[idx].ensure_layer_stack_bufs(
idx,
&mut self.layers_needing_buf_stack,
batch_count,
);
// Note that unlike commands like "mask" or "opacity" which only need to be applied
// to wide tiles with drawing commands, filter commands always need to be applied
// to all wide tiles in the filter bounding-box, because it's possible that after
// applying a filter (like gaussian blur), wide tiles will take on a new color
// even though nothing had been drawn there originally.
self.tiles[idx].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();
}
if layer.needs_buf() {
for &tile_idx in self
.layers_needing_buf_stack
.last()
.unwrap()
.occupied_tiles
.iter()
{
let tile = &mut self.tiles[tile_idx];
// Optimization: If no drawing happened since the last `PushBuf`, then we don't
// need to do any masking or buffer-wide opacity work. Even though we push buffers
// lazily, this can still happen: e.g., filter layers with clips and destructive blends
// currently push layers eagerly.
let has_draw_commands = !matches!(tile.cmds.last().unwrap(), &Cmd::PushBuf(..));
if has_draw_commands {
if let Some(mask) = layer.mask.clone() {
tile.mask(mask);
}
tile.opacity(layer.opacity);
}
// We only need to blend if there are draw commands, unless this is destructive
// blending, in which case we always blend.
if has_draw_commands || layer.blend_mode.is_destructive() {
tile.blend(layer.blend_mode);
}
tile.pop_buf();
}
self.layers_needing_buf_stack.pop();
}
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`
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::ZERO
} 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();
let batch_count = self.batch_count;
// 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 {
let idx = self.get_idx(wtile_x, cur_wtile_y);
self.tiles[idx].push_clip(
idx,
&mut self.layers_needing_buf_stack,
layer_id,
batch_count,
);
}
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();
// Compute base alpha index and create shared clip attributes
// Note: It's possible that the clip-path has zero strips. However, we cannot exit early
// in this case because we need to potentially pop zero clips further below. Therefore,
// we simply use a dummy alpha index of 0 in this case.
let clip_attrs_idx = self.attrs.clip.len() as u32;
let alpha_base_idx;
if n_strips == 0 {
alpha_base_idx = 0;
} else {
alpha_base_idx = strips[0].alpha_idx();
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.
n_zero_clip: usize,
/// The number of non-zero-winding clips.
n_clip: usize,
/// The number of pushed buffers, including buffers for clips.
///
/// Note not all layers require their own buffers; see [`Layer::needs_buf`].
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.
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.
layer_ids: Vec<LayerKind>,
/// Tracks the index into `cmds` of each `Start`/`PushBuf` command on the current stack.
///
/// Only used in `HYBRID` mode.
push_buf_indices: Vec<usize>,
/// Whether at least one filter layer has been pushed on this tile.
had_filter_layer: bool,
/// Indicates whether the main target surface is used as a blend target for a non
/// src-over blending operation.
///
/// This will only be set in `HYBRID` mode.
surface_is_blend_target: bool,
/// Watermark: the batch count at which this tile last emitted `BatchEnd` markers.
/// Only meaningful in `MODE_HYBRID`.
last_batch_end: u32,
}
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,
had_filter_layer: false,
layer_cmd_ranges,
layer_ids: vec![LayerKind::Regular(0)],
push_buf_indices: vec![TARGET_SURFACE_PUSH_BUF_IDX],
surface_is_blend_target: false,
last_batch_end: 0,
}
}
#[inline]
fn reset(&mut self) {
self.bg = PremulColor::from_alpha_color(TRANSPARENT);
self.cmds.clear();
self.n_zero_clip = 0;
self.n_clip = 0;
self.n_bufs = 0;
self.in_clipped_filter_layer = false;
self.had_filter_layer = false;
self.layer_ids.truncate(1);
self.layer_cmd_ranges.clear();
self.layer_cmd_ranges
.insert(0, LayerCommandRanges::default());
self.push_buf_indices.clear();
// We can't use 0 here, because then we have no way of distinguishing it from a
// user-supplied `PushBuf` at position 0.
self.push_buf_indices.push(TARGET_SURFACE_PUSH_BUF_IDX);
self.surface_is_blend_target = false;
self.last_batch_end = 0;
}
/// Push all layer buffers that have not yet been pushed for this tile.
/// Also emit `BatchEnd` commands in case we are in HYBRID mode.
///
/// We push wide tile layer buffers lazily. This is called by wide tile draw methods to ensure
/// all layer stack buffers are pushed for the wide tile. Calling this multiple times won't
/// push additional buffers beyond the first call (until the layer stack changes).
///
/// The `tile_idx` parameter is this tile's index in [`Wide::tiles`].
#[inline(always)]
fn ensure_layer_stack_bufs(
&mut self,
tile_idx: usize,
layers: &mut NeedsBufLayerStack,
batch_count: u32,
) {
// First emit `BatchEnd` commands if necessary.
if MODE == MODE_HYBRID && self.last_batch_end < batch_count {
let count = (batch_count - self.last_batch_end) as usize;
self.cmds.extend(core::iter::repeat_n(Cmd::BatchEnd, count));
self.last_batch_end = batch_count;
}
// Then, take care of emitting `PushBuf` commands that haven't been emitted yet.
// `layers` tracks the number of layers that require scratch buffers, excluding those
// required for clips: clip buffers are handled separately. The scratch buffer stack for
// this tile is `self.n_bufs`, of which `self.n_clip` are for clips.
let layer_bufs = self.n_bufs - self.n_clip;
debug_assert!(
layer_bufs <= layers.len,
"tile `layer_buf_depth` exceeds active layer stack"
);
// It may be quite likely that no buffers need to be pushed: e.g. a tile that has content
// may well have more than one call to `ensure_layer_stack_bufs`, and layers needing
// buffers are not necessarily the most common case. As such, we keep this check inlined
// (because the function itself is inlined), and if buffers are needed do an explicit
// function call that is not inlined. That keeps the generated code size small at our call
// sites.
(layer_bufs < layers.len).then(
#[inline(never)]
|| {
for layer in &mut layers.stack[layer_bufs..layers.len] {
layer.occupied_tiles.push(tile_idx);
self.push_buf(layer.kind);
}
},
);
}
/// 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
fn fill(
&mut self,
batch_count: u32,
tile_idx: usize,
layers: &mut NeedsBufLayerStack,
x: u16,
width: u16,
attrs_idx: u32,
current_layer_id: LayerId,
fill_hint: FillHint,
) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
self.ensure_layer_stack_bufs(tile_idx, layers, batch_count);
// 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
// TODO: Relax this condition to only trigger for filter graphs that contain at
// least 1 primitive filter that has non-local effects (see
// https://github.com/linebender/vello/pull/1526#discussion_r3007377869)
&& !self.had_filter_layer;
if can_override {
match fill_hint {
FillHint::OpaqueSolid(color) => {
self.cmds.clear();
self.bg = color;
// We need to invalidate the ranges of all layers that have been drawn so far
// in that wide tile.
self.layer_cmd_ranges.clear();
self.layer_cmd_ranges
.insert(current_layer_id, LayerCommandRanges::default());
return;
}
FillHint::OpaqueImage => {
// Opaque image: clear previous commands but still emit the fill.
self.cmds.clear();
self.bg = PremulColor::from_alpha_color(TRANSPARENT);
// We need to invalidate the ranges of all layers that have been drawn so far
// in that wide tile.
self.layer_cmd_ranges.clear();
self.layer_cmd_ranges
.insert(current_layer_id, LayerCommandRanges::default());
// 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,
}));
}
}
/// 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.
fn strip(
&mut self,
batch_count: u32,
tile_idx: usize,
layers: &mut NeedsBufLayerStack,
cmd_strip: CmdAlphaFill,
current_layer_id: LayerId,
) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
self.ensure_layer_stack_bufs(tile_idx, layers, batch_count);
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.
fn push_clip(
&mut self,
tile_idx: usize,
layers: &mut NeedsBufLayerStack,
layer_id: LayerId,
batch_count: u32,
) {
if !self.is_zero_clip() || self.in_clipped_filter_layer {
self.ensure_layer_stack_bufs(tile_idx, layers, batch_count);
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.
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.
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.
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.
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).
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.
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.
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;
}
});
}
/// Whether the set of pushed commands so far indicates that the surface is used as
/// a blend target.
pub fn surface_is_blend_target(&self) -> bool {
self.surface_is_blend_target
}
/// 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
#[inline(always)]
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;
});
}
if MODE == MODE_HYBRID {
self.push_buf_indices.push(self.cmds.len());
}
self.cmds.push(Cmd::PushBuf(layer_kind, false));
self.layer_ids.push(layer_kind);
self.n_bufs += 1;
}
/// Pop the most recent buffer.
#[inline(always)]
fn pop_buf(&mut self) {
if MODE == MODE_HYBRID {
self.push_buf_indices.pop();
}
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.
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.had_filter_layer = true;
self.cmds.push(Cmd::Filter(layer_id, filter));
}
/// Apply a mask to the whole buffer.
fn mask(&mut self, mask: Mask) {
self.cmds.push(Cmd::Mask(mask));
}
/// Blend the current buffer into the previous buffer in the stack.
fn blend(&mut self, blend_mode: BlendMode) {
#[allow(clippy::collapsible_if, reason = "better expresses intent")]
if MODE == MODE_HYBRID {
// Whether we use a non-default blend mode to blend into the destination.
let blends_into_dest =
blend_mode.mix != Mix::Normal || blend_mode.compose != Compose::SrcOver;
if blends_into_dest && self.push_buf_indices.len() >= 2 {
let nos_idx = self.push_buf_indices[self.push_buf_indices.len() - 2];
if nos_idx == TARGET_SURFACE_PUSH_BUF_IDX {
self.surface_is_blend_target = true;
} else {
match &mut self.cmds[nos_idx] {
Cmd::PushBuf(_, is_blend_target) => *is_blend_target = true,
// Anything else shouldn't be possible.
_ => unreachable!(),
}
}
}
}
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(Clone, 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.
///
/// The second argument indicates whether the layer that is about to be pushed
/// will be used as a destination for a blending operation with a non-default blend mode.
/// This information is only needed by `vello_hybrid` for scheduling purposes.
PushBuf(LayerKind, bool),
/// 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),
/// Marks a boundary between rendering fast path strips and coarse rasterized strips.
///
/// Only meaningful in `MODE_HYBRID`.
// TODO: Consider removing this, see https://github.com/linebender/vello/pull/1479#discussion_r2869917343.
BatchEnd,
}
#[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, needs_temp) => match (layer_kind, needs_temp) {
(LayerKind::Regular(_), false) => "PushBuf(Regular, false)",
(LayerKind::Regular(_), true) => "PushBuf(Regular, true)",
(LayerKind::Filtered(_), false) => "PushBuf(Filtered, false)",
(LayerKind::Filtered(_), true) => "PushBuf(Filtered, true)",
(LayerKind::Clip(_), false) => "PushBuf(Clip, false)",
(LayerKind::Clip(_), true) => "PushBuf(Clip, true)",
},
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",
Self::BatchEnd => "BatchEnd",
}
}
/// 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(Clone, 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(Clone, 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, NeedsBufLayerStack, Wide, WideTile};
use crate::kurbo::Affine;
use crate::paint::{Paint, PremulColor};
use crate::peniko;
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);
let mut layers = NeedsBufLayerStack::default();
layers.push(LayerKind::Regular(0));
wide.push_buf(LayerKind::Regular(0));
wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None);
wide.fill(0, 0, &mut layers, 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);
let mut layers = NeedsBufLayerStack::default();
layers.push(LayerKind::Regular(0));
wide.push_buf(LayerKind::Regular(0));
wide.fill(0, 0, &mut layers, 0, 10, 0, 0, FillHint::None);
wide.fill(0, 0, &mut layers, 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);
let mut layers = NeedsBufLayerStack::default();
layers.push(LayerKind::Regular(0));
wide.push_buf(LayerKind::Regular(0));
wide.fill(0, 0, &mut layers, 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);
assert_eq!(wide.tiles[0].n_bufs, 0);
wide.reset();
assert_eq!(wide.layer_stack.len(), 0);
assert_eq!(wide.clip_stack.len(), 0);
assert_eq!(wide.tiles[0].n_bufs, 0);
}
#[test]
fn tiles_dirty_flag() {
type ClipPath = Option<Box<[Strip]>>;
let mut wide = Wide::<MODE_CPU>::new(256, 4);
let mut render_graph = RenderGraph::new();
assert!(!wide.tiles_dirty);
let strips = [Strip::new(0, 0, 0, false), Strip::new(10, 0, 4, true)];
wide.generate(
&strips,
Paint::Solid(PremulColor::from_alpha_color(
peniko::color::palette::css::RED,
)),
BlendMode::default(),
0,
None,
&[],
);
assert!(wide.tiles_dirty);
wide.reset();
assert!(!wide.tiles_dirty);
let no_clip: ClipPath = None;
wide.push_layer(
1,
no_clip,
BlendMode::default(),
None,
1.0,
None,
Affine::IDENTITY,
&mut render_graph,
0,
);
assert!(wide.tiles_dirty);
wide.pop_layer(&mut render_graph);
assert!(wide.tiles_dirty);
wide.reset();
assert!(!wide.tiles_dirty);
// Generating with empty strips does not need to mark it as dirty.
wide.generate(
&[],
Paint::Solid(PremulColor::from_alpha_color(
peniko::color::palette::css::RED,
)),
BlendMode::default(),
0,
None,
&[],
);
assert!(!wide.tiles_dirty);
}
}