| // 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 |
| |
| #![expect( |
| clippy::cast_possible_truncation, |
| reason = "We temporarily ignore those because the casts\ |
| only break in edge cases, and some of them are also only related to conversions from f64 to f32." |
| )] |
| |
| use crate::render::common::GpuEncodedImage; |
| use crate::{GpuStrip, RenderError, Scene}; |
| use alloc::collections::VecDeque; |
| use alloc::vec::Vec; |
| use core::mem; |
| use vello_common::peniko::{BlendMode, Compose, Mix}; |
| use vello_common::{ |
| coarse::{Cmd, WideTile}, |
| encode::EncodedPaint, |
| paint::{ImageSource, Paint}, |
| tile::Tile, |
| }; |
| |
| const COLOR_SOURCE_BLEND: u32 = 2; |
| |
| /// Trait for abstracting the renderer backend from the scheduler. |
| pub(crate) trait RendererBackend { |
| /// Clear specific slots in a texture. |
| fn clear_slots(&mut self, texture_index: usize, slots: &[u32]); |
| |
| /// Execute a render pass for strips. |
| fn render_strips(&mut self, strips: &[GpuStrip], target_index: usize, load_op: LoadOp); |
| } |
| |
| /// Backend agnostic enum that specifies the operation to perform to the output attachment at the |
| /// start of a render pass: |
| /// - `LoadOp::Load` is equivalent to `wgpu::LoadOp::Load` |
| /// - `LoadOp::Clear` is equivalent `wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)` |
| pub(crate) enum LoadOp { |
| Load, |
| Clear, |
| } |
| |
| #[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<SlotOccupation>; 2], |
| /// Slots that require clearing before subsequent draws for each slot texture. |
| clear: [Vec<SlotOccupation>; 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, |
| } |
| |
| #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] |
| struct SlotOccupation { |
| slot_idx: usize, |
| texture: u8, |
| } |
| |
| /// 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<SlotOccupation>; 2], |
| } |
| |
| /// State for a single wide tile. |
| #[derive(Debug, Default)] |
| struct TileState { |
| stack: Vec<TileEl>, |
| } |
| |
| #[derive(Clone, Copy, Debug)] |
| struct TileEl { |
| slot_ix: SlotOccupation, |
| slot_prime_ix: Option<SlotOccupation>, |
| round: usize, |
| opacity: f32, |
| } |
| |
| #[derive(Debug, Default)] |
| struct Draw(Vec<GpuStrip>); |
| |
| impl Scheduler { |
| pub(crate) fn new(total_slots: usize) -> Self { |
| let free0: Vec<_> = (0..total_slots) |
| .map(|idx| SlotOccupation { |
| slot_idx: idx, |
| texture: 0, |
| }) |
| .collect(); |
| let free1 = free0 |
| .clone() |
| .into_iter() |
| .map(|SlotOccupation { slot_idx, .. }| SlotOccupation { |
| slot_idx, |
| texture: 1, |
| }) |
| .collect(); |
| 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(), |
| } |
| } |
| |
| /// This looks ahead to see if another PushBuf is incoming. If so - we will be "blending". |
| fn should_allocate_prime(cmds: &[Cmd], current_idx: usize) -> bool { |
| for cmd in &cmds[current_idx + 1..] { |
| match cmd { |
| Cmd::PushBuf => return true, |
| Cmd::PopBuf => return false, // End of this buffer's scope |
| _ => continue, |
| } |
| } |
| false |
| } |
| |
| pub(crate) fn do_scene<R: RendererBackend>( |
| &mut self, |
| renderer: &mut R, |
| scene: &Scene, |
| ) -> Result<(), RenderError> { |
| let mut tile_state = mem::take(&mut self.tile_state); |
| let wide_tiles_per_row = scene.wide.width_tiles(); |
| let wide_tiles_per_col = scene.wide.height_tiles(); |
| println!("do_scene - tile: {{width: {wide_tiles_per_row}, height: {wide_tiles_per_col} }}"); |
| println!("self.tile_state len: {tile_state:?}"); |
| |
| // 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 = scene.wide.get(wide_tile_col, wide_tile_row); |
| let wide_tile_x = wide_tile_col * WideTile::WIDTH; |
| let wide_tile_y = wide_tile_row * Tile::HEIGHT; |
| self.do_tile( |
| renderer, |
| scene, |
| wide_tile_x, |
| wide_tile_y, |
| wide_tile, |
| &mut tile_state, |
| )?; |
| } |
| } |
| while !self.rounds_queue.is_empty() { |
| self.flush(renderer); |
| } |
| |
| // 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(&SlotOccupation { |
| slot_idx: i, |
| texture: 0 |
| }), |
| "free[0] is missing slot {i}" |
| ); |
| debug_assert!( |
| self.free[1].contains(&SlotOccupation { |
| slot_idx: i, |
| texture: 1 |
| }), |
| "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<R: RendererBackend>(&mut self, renderer: &mut R) { |
| let round = self.rounds_queue.pop_front().unwrap(); |
| println!("Drawing a round:"); |
| dbg!(&round); |
| 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. |
| 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(); |
| LoadOp::Clear |
| } else { |
| // Some slots need to be preserved, so only clear the dirty slots. |
| renderer.clear_slots( |
| i, |
| self.clear[i] |
| .iter() |
| .map(|slot| slot.slot_idx as u32) |
| .collect::<Vec<u32>>() |
| .as_slice(), |
| ); |
| self.clear[i].clear(); |
| LoadOp::Load |
| } |
| }; |
| renderer.render_strips(&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 { |
| // We are drawing on one of the two buffer slot textures |
| 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<R: RendererBackend>( |
| &mut self, |
| renderer: &mut R, |
| scene: &Scene, |
| 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: SlotOccupation { slot_idx: usize::MAX, texture: 0 }, |
| slot_prime_ix: None, |
| round: self.round, |
| opacity: 1., |
| }); |
| { |
| // 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_idx: 0, |
| payload: bg, |
| paint: 0, |
| }); |
| } |
| } |
| for (cmd_idx, cmd) in dbg!(tile).cmds.iter().enumerate() { |
| // 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(); |
| |
| // If prime exists, act as if we're one level deeper |
| let effective_depth = if el.slot_prime_ix.is_some() { |
| clip_depth + 1 |
| } else { |
| clip_depth |
| }; |
| |
| let draw = self.draw_mut(el.round, effective_depth); |
| |
| let (scene_strip_x, scene_strip_y) = (wide_tile_x + fill.x, wide_tile_y); |
| let (payload, paint) = |
| Self::process_paint(&fill.paint, scene, (scene_strip_x, scene_strip_y)); |
| |
| // Determine which slot to write to and its Y position |
| let y = if let Some(prime_slot) = el.slot_prime_ix { |
| if effective_depth == 1 { |
| scene_strip_y |
| } else { |
| prime_slot.slot_idx as u16 * Tile::HEIGHT |
| } |
| } else { |
| // Write to main slot |
| if clip_depth == 1 { |
| scene_strip_y |
| } else { |
| el.slot_ix.slot_idx as u16 * Tile::HEIGHT |
| } |
| }; |
| |
| let x = if effective_depth == 1 { |
| scene_strip_x |
| } else { |
| fill.x |
| }; |
| |
| draw.0.push(GpuStrip { |
| x, |
| y, |
| width: fill.width, |
| dense_width: 0, |
| col_idx: 0, |
| payload, |
| paint, |
| }); |
| } |
| Cmd::AlphaFill(alpha_fill) => { |
| let el = state.stack.last().unwrap(); |
| let effective_depth = if el.slot_prime_ix.is_some() { |
| clip_depth + 1 |
| } else { |
| clip_depth |
| }; |
| let draw = self.draw_mut(el.round, effective_depth); |
| |
| let col_idx = (alpha_fill.alpha_idx / usize::from(Tile::HEIGHT)) |
| .try_into() |
| .expect("Sparse strips are bound to u32 range"); |
| |
| let (scene_strip_x, scene_strip_y) = (wide_tile_x + alpha_fill.x, wide_tile_y); |
| let (payload, paint) = Self::process_paint( |
| &alpha_fill.paint, |
| scene, |
| (scene_strip_x, scene_strip_y), |
| ); |
| |
| // Determine Y position based on which slot we're writing to |
| let y = if let Some(prime_slot) = el.slot_prime_ix { |
| // Write to temporary slot |
| if effective_depth == 1 { |
| scene_strip_y |
| } else { |
| prime_slot.slot_idx as u16 * Tile::HEIGHT |
| } |
| } else { |
| if effective_depth == 1 { |
| scene_strip_y |
| } else { |
| el.slot_ix.slot_idx as u16 * Tile::HEIGHT |
| } |
| }; |
| |
| let x = if effective_depth == 1 { |
| scene_strip_x |
| } else { |
| alpha_fill.x |
| }; |
| |
| draw.0.push(GpuStrip { |
| x, |
| y, |
| width: alpha_fill.width, |
| dense_width: alpha_fill.width, |
| col_idx, |
| payload, |
| paint, |
| }); |
| } |
| Cmd::PushBuf => { |
| if Self::should_allocate_prime(&tile.cmds, cmd_idx) { |
| let dest_ix = (1 - clip_depth) % 2; // Destination texture (prime slot) |
| let temp_ix = clip_depth % 2; // Temporary texture |
| |
| // while self.free[dest_ix].is_empty() || self.free[temp_ix].is_empty() { |
| // if self.rounds_queue.is_empty() { |
| // return Err(RenderError::SlotsExhausted); |
| // } |
| // self.flush(renderer); |
| // } |
| |
| // Allocate both |
| let dest_slot = self.free[dest_ix].pop().unwrap(); |
| let temp_slot = self.free[temp_ix].pop().unwrap(); |
| |
| self.clear[dest_ix].push(dest_slot); |
| self.clear[temp_ix].push(temp_slot); |
| |
| state.stack.push(TileEl { |
| slot_ix: dest_slot, // Destination (prime) - blend result. |
| slot_prime_ix: Some(temp_slot), // Temporary accumulation of fills before blending... |
| round: self.round, |
| opacity: 1., |
| }); |
| } else { |
| let ix = clip_depth % 2; |
| while self.free[ix].is_empty() { |
| if self.rounds_queue.is_empty() { |
| return Err(RenderError::SlotsExhausted); |
| } |
| self.flush(renderer); |
| } |
| let slot_ix = self.free[ix].pop().unwrap(); |
| self.clear[ix].push(slot_ix); |
| |
| state.stack.push(TileEl { |
| slot_ix, |
| slot_prime_ix: None, |
| round: self.round, |
| opacity: 1., |
| }); |
| } |
| } |
| Cmd::PopBuf => { |
| // Pop Buffer can push freeing of slots. |
| let popped_buffer = state.stack.pop().unwrap(); |
| let parent_buffer = state.stack.last_mut().unwrap(); |
| let next_round = clip_depth % 2 == 0 && clip_depth > 2; |
| let round = parent_buffer |
| .round |
| .max(popped_buffer.round + usize::from(next_round)); |
| parent_buffer.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[popped_buffer.slot_ix.texture as usize] |
| .push(popped_buffer.slot_ix); |
| |
| if let Some(slot_prime_ix) = popped_buffer.slot_prime_ix { |
| self.rounds_queue[round - self.round].free[slot_prime_ix.texture as usize] |
| .push(slot_prime_ix); |
| } |
| } |
| Cmd::ClipFill(clip_fill) => { |
| let clip_source = &state.stack[clip_depth - 1]; |
| let clip_target = &state.stack[clip_depth - 2]; |
| let next_round = clip_depth % 2 == 0 && clip_depth > 2; |
| let round = clip_target |
| .round |
| .max(clip_source.round + usize::from(next_round)); |
| 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, |
| clip_target.slot_ix.slot_idx as u16 * Tile::HEIGHT, |
| ) |
| }; |
| // Opacity packed into the first 8 bits – pack full opacity (0xFF). |
| let paint = COLOR_SOURCE_SLOT << 30 | 0xFF; |
| draw.0.push(GpuStrip { |
| x, |
| y, |
| width: clip_fill.width as u16, |
| dense_width: 0, |
| col_idx: 0, |
| payload: clip_source.slot_ix.slot_idx as u32, |
| paint, |
| }); |
| } |
| Cmd::ClipStrip(clip_alpha_fill) => { |
| let clip_source = &state.stack[clip_depth - 1]; |
| let clip_target = &state.stack[clip_depth - 2]; |
| let next_round = clip_depth % 2 == 0 && clip_depth > 2; |
| let round = clip_target |
| .round |
| .max(clip_source.round + usize::from(next_round)); |
| 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, |
| clip_target.slot_ix.slot_idx as u16 * Tile::HEIGHT, |
| ) |
| }; |
| // Opacity packed into the first 8 bits – pack full opacity (0xFF). |
| let paint = COLOR_SOURCE_SLOT << 30 | 0xFF; |
| draw.0.push(GpuStrip { |
| x, |
| y, |
| width: clip_alpha_fill.width as u16, |
| dense_width: clip_alpha_fill.width as u16, |
| col_idx: (clip_alpha_fill.alpha_idx / usize::from(Tile::HEIGHT)) |
| .try_into() |
| .expect("Sparse strips are bound to u32 range"), |
| payload: clip_source.slot_ix.slot_idx as u32, |
| paint, |
| }); |
| } |
| Cmd::Opacity(opacity) => { |
| state.stack.last_mut().unwrap().opacity = *opacity; |
| } |
| Cmd::Blend(mode) => { |
| let blend_source = state.stack.last().unwrap(); |
| |
| let blend_target = &state.stack[state.stack.len() - 2]; |
| |
| // Determine where the actual content is for source |
| let source_content_slot = |
| blend_source.slot_prime_ix.unwrap_or(blend_source.slot_ix); |
| |
| if let Some(dest_temp_slot) = blend_target.slot_prime_ix { |
| // Case 1: Isolated blend |
| // Sources are in temporary slots, write to destination slot |
| |
| let next_round = clip_depth % 2 == 0 && clip_depth > 2; |
| let round = blend_target |
| .round |
| .max(blend_source.round + usize::from(next_round)); |
| |
| // Write to the destination slot (opposite texture from sources) |
| let draw = self.draw_mut(round, clip_depth - 1); |
| |
| let (x, y) = if clip_depth <= 2 { |
| (wide_tile_x, wide_tile_y) |
| } else { |
| (0, blend_target.slot_ix.slot_idx as u16 * Tile::HEIGHT) // Write to destination |
| }; |
| |
| // debug_assert_ne!(dest_temp_slot, source_content_slot, "cmd_idx: {cmd_idx}"); |
| |
| // Encode blend operation |
| let paint = (COLOR_SOURCE_BLEND << 30) |
| | ((dest_temp_slot.slot_idx as u32 & 0x3FFF) << 16) |
| | ((mode.mix as u32) << 8) |
| | (mode.compose as u32); |
| |
| let payload = source_content_slot.slot_idx as u32; // Where source content is |
| |
| draw.0.push(GpuStrip { |
| x, |
| y, |
| width: WideTile::WIDTH, |
| dense_width: 0, |
| col_idx: 0, |
| payload, |
| paint, |
| }); |
| } else { |
| // Case 2: No prime slot - standard SrcOver to target |
| let next_round = clip_depth % 2 == 0 && clip_depth > 2; |
| let round = blend_target |
| .round |
| .max(blend_source.round + usize::from(next_round)); |
| let draw = self.draw_mut(round, clip_depth - 1); |
| |
| let (x, y) = if clip_depth <= 2 { |
| (wide_tile_x, wide_tile_y) |
| } else { |
| (0, blend_target.slot_ix.slot_idx as u16 * Tile::HEIGHT) |
| }; |
| |
| let opacity_u8 = (blend_source.opacity * 255.0) as u32; |
| let paint = (COLOR_SOURCE_SLOT << 30) | opacity_u8; |
| |
| draw.0.push(GpuStrip { |
| x, |
| y, |
| width: WideTile::WIDTH, |
| dense_width: 0, |
| col_idx: 0, |
| payload: source_content_slot.slot_idx as u32, // Use actual content location |
| paint, |
| }); |
| } |
| } |
| _ => unimplemented!(), |
| } |
| } |
| |
| if state.stack.len() > 1 { |
| // Had nested operations |
| // Force a new round for the next tile |
| while !self.rounds_queue.is_empty() { |
| self.flush(renderer); |
| } |
| } |
| |
| Ok(()) |
| } |
| |
| /// Process a paint and return (`payload`, `paint`) |
| fn process_paint( |
| paint: &Paint, |
| scene: &Scene, |
| (scene_strip_x, scene_strip_y): (u16, u16), |
| ) -> (u32, u32) { |
| match paint { |
| Paint::Solid(color) => { |
| 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 paint_packed = (COLOR_SOURCE_PAYLOAD << 30) | (PAINT_TYPE_SOLID << 28); |
| (rgba, paint_packed) |
| } |
| Paint::Indexed(indexed_paint) => { |
| let paint_id = indexed_paint.index(); |
| // 16 bytes per texel: Rgba32Uint (4 bytes) * 4 (4 texels) |
| let paint_tex_id = (paint_id * size_of::<GpuEncodedImage>() / 16) as u32; |
| |
| match scene.encoded_paints.get(paint_id) { |
| Some(EncodedPaint::Image(encoded_image)) => match &encoded_image.source { |
| ImageSource::OpaqueId(_) => { |
| let paint_packed = (COLOR_SOURCE_PAYLOAD << 30) |
| | (PAINT_TYPE_IMAGE << 28) |
| | (paint_tex_id & 0x0FFFFFFF); |
| let scene_strip_xy = |
| ((scene_strip_y as u32) << 16) | (scene_strip_x as u32); |
| (scene_strip_xy, paint_packed) |
| } |
| _ => unimplemented!("Unsupported image source"), |
| }, |
| _ => unimplemented!("Unsupported paint type"), |
| } |
| } |
| } |
| } |
| } |
| |
| const COLOR_SOURCE_PAYLOAD: u32 = 0; |
| const COLOR_SOURCE_SLOT: u32 = 1; |
| |
| const PAINT_TYPE_SOLID: u32 = 0; |
| const PAINT_TYPE_IMAGE: u32 = 1; |
| |
| #[inline(always)] |
| fn has_non_zero_alpha(rgba: u32) -> bool { |
| rgba >= 0x1_00_00_00 |
| } |