blob: 2147c904565c253e7f5115e61773e7582e13c22b [file]
// Copyright 2025 the Vello Authors
// SPDX-License-Identifier: Apache-2.0 OR MIT
//! # Scheduling
//!
//! - Draw commands are either issued to the final target or slots in a clip texture.
//! - Rounds represent a draw in up to 3 render targets (two clip textures and a final target).
//! - The clip texture stores slots for many clip depths. Once our clip textures are full,
//! we flush rounds (i.e. execute render passes) to free up space. Note that a slot refers
//! to 1 wide tile's worth of pixels in the clip texture.
//! - The `free` vector contains the indices of the slots that are available for use in the two clip textures.
//!
//! ## Example
//!
//! Consider the following scene of drawing a single wide tile with three overlapping rectangles with
//! decreasing width clipping regions.
//!
//! ```rs
//! const WIDTH: f64 = 100.0;
//! const HEIGHT: f64 = Tile::HEIGHT as f64;
//! const OFFSET: f64 = WIDTH / 3.0;
//!
//! let colors = [RED, GREEN, BLUE];
//!
//! for i in 0..3 {
//! let clip_rect = Rect::new((i as f64) * OFFSET, 0.0, 100, HEIGHT);
//! ctx.push_clip_layer(&clip_rect.to_path(0.1));
//! ctx.set_paint(colors[i]);
//! ctx.fill_rect(&Rect::new(0.0, 0.0, WIDTH, HEIGHT));
//! }
//! for _ in 0..3 {
//! ctx.pop_layer();
//! }
//! ```
//!
//! This single wide tile scene should produce the below rendering:
//!
//! ┌────────────────────────────┌────────────────────────────┌─────────────────────────────
//! │ ── ─── │ / / / /│ ────────────── │
//! │ ──── ──── │ / / / / │──────── │
//! │── ───── │ / / / / │ │
//! │ ───Red │ / /Green / / │ Blue │
//! │ ──── ── │ / / / / │ ───────│
//! │ ─── ──── │ / / / / │ ────────────── │
//! │ ── │ / / / / │─────── │
//! └────────────────────────────└────────────────────────────└────────────────────────────┘
//!
//! How the scene is scheduled into rounds and draw calls are shown below:
//!
//! ### Round 0
//!
//! In this round, we don't have any preserved slots or slots that we need to sample from. Simply,
//! draw unclipped primitives.
//!
//! ### Draw to texture 0:
//!
//! In Slot N - 1 of texture 0, draw the unclipped green rectangle.
//!
//! Slot N - 1:
//! ┌──────────────────────────────────────────────────────────────────────────────────────┐
//! │ / / / / / / / / / / / / │
//! │ / / / / / / / / / / / / │
//! │ / / / / / / / / / / / / │
//! │ / / / / / / Green / / / / / / │
//! │ / / / / / / / / / / / / │
//! │ / / / / / / / / / / / / │
//! │ / / / / / / / / / / / / │
//! └──────────────────────────────────────────────────────────────────────────────────────┘
//!
//! ### Draw to texture 1:
//!
//! In Slot N - 2 of texture 1, draw unclipped red rectangle and, in slot N - 1, draw the unclipped
//! blue rectangle.
//!
//! Slot N - 2:
//! ┌──────────────────────────────────────────────────────────────────────────────────────┐
//! │ ── ─── ── ─── │
//! │ ──── ──── ── ──── ──── ──│
//! │── ───── ──── ── ───── ──── │
//! │ ───── ──── Red ───── ──── │
//! │ ──── ──── ──── ──── │
//! │ ─── ──── ─── ──── │
//! │ ─── ─── │
//! └──────────────────────────────────────────────────────────────────────────────────────┘
//! Slot N - 1:
//! ┌──────────────────────────────────────────────────────────────────────────────────────┐
//! │ ────────────────────────────────────────── │
//! │─────────────────────────────────────────── │
//! │ │
//! │ Blue ───────────────│
//! │ ──────────────────────────── │
//! │ ──────────────────────────── │
//! │─────────────── │
//! └──────────────────────────────────────────────────────────────────────────────────────┘
//!
//! ### Round 1
//!
//! At this point, we have three slots that contain our unclipped rectangles. In this round,
//! we start to sample those pixels to apply clipping (texture 1 samples from texture 0 and
//! the render target view samples from texture 1).
//!
//! ### Draw to texture 0:
//!
//! Slot N - 1 of texture 0 contains our unclipped green rectangle. In this draw, we sample
//! the pixels from slot N - 2 from texture 1 to draw the blue rectangle into this slot.
//!
//! Slot N - 1:
//! ┌─────────────────────────────────────────────────────────┌─────────────────────────────
//! │ / / / / / / / /│ ────────────── │
//! │ / / / / / / / / │──────── │
//! │ / / / / / / / / │ │
//! │ / / / Green / / / / │ Blue │
//! │ / / / / / / / / │ ───────│
//! │ / / / / / / / / │ ────────────── │
//! │ / / / / / / / / │─────── │
//! └─────────────────────────────────────────────────────────└────────────────────────────┘
//!
//! ### Draw to texture 1:
//!
//! Then, into Slot N - 2 of texture 1, which contains our red rectangle, we sample the pixels
//! from slot N - 1 of texture 0 which contain our green and blue rectangles.
//!
//! ┌────────────────────────────┌────────────────────────────┌─────────────────────────────
//! │ ── ─── │ / / / /│ ────────────── │
//! │ ──── ──── │ / / / / │──────── │
//! │── ───── │ / / / / │ │
//! │ ───Red │ / /Green / / │ Blue │
//! │ ──── ── │ / / / / │ ───────│
//! │ ─── ──── │ / / / / │ ────────────── │
//! │ ── │ / / / / │─────── │
//! └────────────────────────────└────────────────────────────└────────────────────────────┘
//!
//! ### Draw to render target
//!
//! At this point, we can sample the pixels from slot N - 1 of texture 1 to draw the final
//! result.
//!
//! ## Nuances
//!
//! - When there are no clip/blend regions, we can render directly to the final target.
//! - The above example provides an intuitive explanation for how rounds after 3 clip depths
//! are scheduled. At clip depths 1 and 2, we can draw directly to the final target within a
//! single round.
//! - Before drawing into any slot, we need to clear it. If all slots can be cleared or are free,
//! we can use a `LoadOp::Clear` operation. Otherwise, we need to clear the dirty slots using
//! a fine grained render pass.
//!
//! ## Clip Depths, Textures, and Rendering
//!
//! The relationship between clip depths, textures, and rendering is as follows:
//!
//! 1. For `clip_depth` 1 (conceptually no clipping):
//! - Direct rendering to the final target (ix=2)
//!
//! 2. For `clip_depth` 2 (conceptually first level of clipping):
//! - Draw to the odd texture (ix=1)
//! - Final drawing samples from odd texture to the final target
//!
//! 3. For `clip_depth` 3+ (conceptually second level of clipping and beyond):
//! - For odd clip depths:
//! - Draw initially to the odd texture (ix=1)
//! - For even clip depths:
//! - Draw initially to the even texture (ix=0)
//! - Sampling occurs similarly to the above example.
//!
//! Note: The code implementation uses a 1-indexed system where `clip_depth` starts at 1
//! even when there is conceptually no clipping.
//!
//! For more information about this algorithm, see this [Zulip thread].
//!
//! [Zulip thread]: https://xi.zulipchat.com/#narrow/channel/197075-vello/topic/Spatiotemporal.20allocation.20.28hybrid.29/near/513442829
use crate::{GpuStrip, RenderError, Scene, render::RendererJunk};
use alloc::collections::VecDeque;
use alloc::vec::Vec;
use core::mem;
use vello_common::{
coarse::{Cmd, WideTile},
paint::Paint,
tile::Tile,
};
#[derive(Debug)]
pub(crate) struct Scheduler {
/// Index of the current round
round: usize,
/// The total number of slots in each slot texture.
total_slots: usize,
/// The slots that are free to use in each slot texture.
free: [Vec<usize>; 2],
/// Slots that require clearing before subsequent draws for each slot texture.
clear: [Vec<u32>; 2],
/// Rounds are enqueued on push clip commands and dequeued on flush.
rounds_queue: VecDeque<Round>,
/// State for a single wide tile.
tile_state: TileState,
}
/// A "round" is a coarse scheduling quantum.
///
/// It represents draws in up to three render targets; two for intermediate
/// clip/blend buffers, and the third for the actual render target.
#[derive(Debug, Default)]
struct Round {
/// Draw calls scheduled into the two slot textures (0, 1) and the final target (2).
draws: [Draw; 3],
/// Slots that will be freed after drawing into the two slot textures [0, 1].
free: [Vec<usize>; 2],
}
/// State for a single wide tile.
#[derive(Debug, Default)]
struct TileState {
stack: Vec<TileEl>,
}
#[derive(Clone, Copy, Debug)]
struct TileEl {
slot_ix: usize,
round: usize,
}
#[derive(Debug, Default)]
struct Draw(Vec<GpuStrip>);
impl Scheduler {
pub(crate) fn new(total_slots: usize) -> Self {
let free0: Vec<_> = (0..total_slots).collect();
let free1 = free0.clone();
let free = [free0, free1];
let clear = [Vec::new(), Vec::new()];
Self {
round: 0,
total_slots,
free,
clear,
rounds_queue: Default::default(),
tile_state: Default::default(),
}
}
pub(crate) fn do_scene(
&mut self,
junk: &mut RendererJunk<'_>,
scene: &Scene,
) -> Result<(), RenderError> {
let mut tile_state = mem::take(&mut self.tile_state);
let wide_tiles_per_row = (scene.width).div_ceil(WideTile::WIDTH);
let wide_tiles_per_col = (scene.height).div_ceil(Tile::HEIGHT);
// Left to right, top to bottom iteration over wide tiles.
for wide_tile_row in 0..wide_tiles_per_col {
for wide_tile_col in 0..wide_tiles_per_row {
let wide_tile_idx = usize::from(wide_tile_row * wide_tiles_per_row + wide_tile_col);
let wide_tile = &scene.wide.tiles[wide_tile_idx];
let wide_tile_x = wide_tile_col * WideTile::WIDTH;
let wide_tile_y = wide_tile_row * Tile::HEIGHT;
self.do_tile(junk, wide_tile_x, wide_tile_y, wide_tile, &mut tile_state)?;
}
}
while !self.rounds_queue.is_empty() {
self.flush(junk);
}
// Restore state to reuse allocations.
self.round = 0;
self.tile_state = tile_state;
self.tile_state.stack.clear();
debug_assert!(self.clear[0].is_empty(), "clear has not reset");
debug_assert!(self.clear[1].is_empty(), "clear has not reset");
#[cfg(debug_assertions)]
{
for i in 0..self.total_slots {
debug_assert!(self.free[0].contains(&i), "free[0] is missing slot {}", i);
debug_assert!(self.free[1].contains(&i), "free[1] is missing slot {}", i);
}
}
debug_assert!(self.rounds_queue.is_empty(), "rounds_queue is not empty");
Ok(())
}
/// Flush one round.
///
/// The rounds queue must not be empty.
fn flush(&mut self, junk: &mut RendererJunk<'_>) {
let round = self.rounds_queue.pop_front().unwrap();
for (i, draw) in round.draws.iter().enumerate() {
if draw.0.is_empty() {
continue;
}
let load = {
if i == 2 {
// We're rendering to the view, don't clear.
wgpu::LoadOp::Load
} else if self.clear[i].len() + self.free[i].len() == self.total_slots {
// All slots are either unoccupied or need to be cleared.
// Simply clear the slots via a load operation.
self.clear[i].clear();
wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
} else {
// Some slots need to be preserved, so only clear the dirty slots.
junk.do_clear_slots_render_pass(i, self.clear[i].as_slice());
self.clear[i].clear();
wgpu::LoadOp::Load
}
};
junk.do_strip_render_pass(&draw.0, i, load);
}
for i in 0..2 {
self.free[i].extend(&round.free[i]);
}
self.round += 1;
}
// Find the appropriate draw call for rendering.
fn draw_mut(&mut self, el_round: usize, clip_depth: usize) -> &mut Draw {
let ix = if clip_depth == 1 {
// We can draw to the final target
2
} else {
1 - clip_depth % 2
};
let rel_round = el_round.saturating_sub(self.round);
if self.rounds_queue.len() == rel_round {
self.rounds_queue.push_back(Round::default());
}
&mut self.rounds_queue[rel_round].draws[ix]
}
/// Iterates over wide tile commands and schedules them for rendering.
fn do_tile(
&mut self,
junk: &mut RendererJunk<'_>,
wide_tile_x: u16,
wide_tile_y: u16,
tile: &WideTile,
state: &mut TileState,
) -> Result<(), RenderError> {
state.stack.clear();
// Sentinel `TileEl` to indicate the end of the stack where we draw all
// commands to the final target.
state.stack.push(TileEl {
slot_ix: usize::MAX,
round: self.round,
});
{
// If the background has a non-zero alpha then we need to render it.
let bg = tile.bg.as_premul_rgba8().to_u32();
if has_non_zero_alpha(bg) {
let draw = self.draw_mut(self.round, 1);
draw.0.push(GpuStrip {
x: wide_tile_x,
y: wide_tile_y,
width: WideTile::WIDTH,
dense_width: 0,
col: 0,
rgba: bg,
});
}
}
for cmd in &tile.cmds {
// Note: this starts at 1 (for the final target)
let clip_depth = state.stack.len();
match cmd {
Cmd::Fill(fill) => {
let el = state.stack.last().unwrap();
let draw = self.draw_mut(el.round, clip_depth);
let color = match fill.paint {
Paint::Solid(color) => color,
Paint::Indexed(_) => unimplemented!(),
};
let rgba = color.as_premul_rgba8().to_u32();
debug_assert!(
has_non_zero_alpha(rgba),
"Color fields with 0 alpha are reserved for clipping"
);
let (x, y) = if clip_depth == 1 {
(wide_tile_x + fill.x, wide_tile_y)
} else {
(fill.x, el.slot_ix as u16 * Tile::HEIGHT)
};
draw.0.push(GpuStrip {
x,
y,
width: fill.width,
dense_width: 0,
col: 0,
rgba,
});
}
Cmd::AlphaFill(alpha_fill) => {
let el = state.stack.last().unwrap();
let draw = self.draw_mut(el.round, clip_depth);
let color = match alpha_fill.paint {
Paint::Solid(color) => color,
Paint::Indexed(_) => unimplemented!(),
};
let rgba = color.as_premul_rgba8().to_u32();
debug_assert!(
has_non_zero_alpha(rgba),
"Color fields with 0 alpha are reserved for clipping"
);
let (x, y) = if clip_depth == 1 {
(wide_tile_x + alpha_fill.x, wide_tile_y)
} else {
(alpha_fill.x, el.slot_ix as u16 * Tile::HEIGHT)
};
draw.0.push(GpuStrip {
x,
y,
width: alpha_fill.width,
dense_width: alpha_fill.width,
col: (alpha_fill.alpha_idx / usize::from(Tile::HEIGHT))
.try_into()
.expect("Sparse strips are bound to u32 range"),
rgba,
});
}
Cmd::PushBuf => {
let ix = clip_depth % 2;
while self.free[ix].is_empty() {
if self.rounds_queue.is_empty() {
return Err(RenderError::SlotsExhausted);
}
self.flush(junk);
}
let slot_ix = self.free[ix].pop().unwrap();
self.clear[ix].push(slot_ix as u32);
state.stack.push(TileEl {
slot_ix,
round: self.round,
});
}
Cmd::PopBuf => {
let tos = state.stack.pop().unwrap();
let nos = state.stack.last_mut().unwrap();
let next_round = clip_depth % 2 == 0 && clip_depth > 2;
let round = nos.round.max(tos.round + next_round as usize);
nos.round = round;
// free slot after draw
debug_assert!(round >= self.round, "round must be after current round");
debug_assert!(
round - self.round < self.rounds_queue.len(),
"round must be in queue"
);
self.rounds_queue[round - self.round].free[1 - clip_depth % 2]
.push(tos.slot_ix);
}
Cmd::ClipFill(clip_fill) => {
let tos = &state.stack[clip_depth - 1];
let nos = &state.stack[clip_depth - 2];
let next_round = clip_depth % 2 == 0 && clip_depth > 2;
let round = nos.round.max(tos.round + next_round as usize);
let draw = self.draw_mut(round, clip_depth - 1);
let (x, y) = if clip_depth <= 2 {
(wide_tile_x + clip_fill.x as u16, wide_tile_y)
} else {
(clip_fill.x as u16, nos.slot_ix as u16 * Tile::HEIGHT)
};
draw.0.push(GpuStrip {
x,
y,
width: clip_fill.width as u16,
dense_width: 0,
col: 0,
rgba: tos.slot_ix as u32,
});
}
Cmd::ClipStrip(clip_alpha_fill) => {
let tos = &state.stack[clip_depth - 1];
let nos = &state.stack[clip_depth - 2];
let next_round = clip_depth % 2 == 0 && clip_depth > 2;
let round = nos.round.max(tos.round + next_round as usize);
let draw = self.draw_mut(round, clip_depth - 1);
let (x, y) = if clip_depth <= 2 {
(wide_tile_x + clip_alpha_fill.x as u16, wide_tile_y)
} else {
(clip_alpha_fill.x as u16, nos.slot_ix as u16 * Tile::HEIGHT)
};
draw.0.push(GpuStrip {
x,
y,
width: clip_alpha_fill.width as u16,
dense_width: clip_alpha_fill.width as u16,
col: (clip_alpha_fill.alpha_idx / usize::from(Tile::HEIGHT))
.try_into()
.expect("Sparse strips are bound to u32 range"),
rgba: tos.slot_ix as u32,
});
}
_ => unimplemented!(),
}
}
Ok(())
}
}
#[inline(always)]
fn has_non_zero_alpha(rgba: u32) -> bool {
rgba >= 0x1_00_00_00
}