vello_hybrid: Create a reusable state for tiles and buffers (#1395)

Before (1110ms):
<img width="1483" height="418" alt="image"
src="https://github.com/user-attachments/assets/9a24bda0-d946-4a75-b709-a4db9a7fdd4e"
/>

After (863ms):
<img width="1471" height="503" alt="image"
src="https://github.com/user-attachments/assets/9a3bcae5-363e-47eb-a288-2c0196ef26bd"
/>
diff --git a/sparse_strips/vello_hybrid/src/render/webgl.rs b/sparse_strips/vello_hybrid/src/render/webgl.rs
index 89564e0..f350b43 100644
--- a/sparse_strips/vello_hybrid/src/render/webgl.rs
+++ b/sparse_strips/vello_hybrid/src/render/webgl.rs
@@ -36,7 +36,7 @@
         },
     },
     scene::Scene,
-    schedule::{LoadOp, RendererBackend, Scheduler},
+    schedule::{LoadOp, RendererBackend, Scheduler, SchedulerState},
 };
 
 use alloc::sync::Arc;
@@ -82,6 +82,8 @@
     programs: WebGlPrograms,
     /// Scheduler for scheduling draws.
     scheduler: Scheduler,
+    /// The state used by the scheduler.
+    scheduler_state: SchedulerState,
     /// WebGL context.
     gl: WebGl2RenderingContext,
     /// Image cache for storing images atlas allocations.
@@ -152,6 +154,7 @@
         Self {
             programs: WebGlPrograms::new(gl.clone(), &image_cache, total_slots),
             scheduler: Scheduler::new(total_slots),
+            scheduler_state: SchedulerState::default(),
             gl,
             image_cache,
             encoded_paints: Vec::new(),
@@ -189,7 +192,8 @@
             programs: &mut self.programs,
             gl: &self.gl,
         };
-        self.scheduler.do_scene(&mut ctx, scene, &self.paint_idxs)?;
+        self.scheduler
+            .do_scene(&mut self.scheduler_state, &mut ctx, scene, &self.paint_idxs)?;
         self.gradient_cache.maintain();
 
         // Blit the view framebuffer to the default framebuffer (canvas element), reflecting the
diff --git a/sparse_strips/vello_hybrid/src/render/wgpu.rs b/sparse_strips/vello_hybrid/src/render/wgpu.rs
index 5f64c06..2a90143 100644
--- a/sparse_strips/vello_hybrid/src/render/wgpu.rs
+++ b/sparse_strips/vello_hybrid/src/render/wgpu.rs
@@ -39,7 +39,7 @@
         },
     },
     scene::Scene,
-    schedule::{LoadOp, RendererBackend, Scheduler},
+    schedule::{LoadOp, RendererBackend, Scheduler, SchedulerState},
 };
 use bytemuck::{Pod, Zeroable};
 use vello_common::{
@@ -83,6 +83,8 @@
     programs: Programs,
     /// Scheduler for scheduling draws.
     scheduler: Scheduler,
+    /// The state used by the scheduler.
+    scheduler_state: SchedulerState,
     /// Image cache for storing images atlas allocations.
     image_cache: ImageCache,
     /// Encoded paints for storing encoded paints.
@@ -119,6 +121,7 @@
         Self {
             programs: Programs::new(device, &image_cache, render_target_config, total_slots),
             scheduler: Scheduler::new(total_slots),
+            scheduler_state: SchedulerState::default(),
             image_cache,
             gradient_cache,
             encoded_paints: Vec::new(),
@@ -160,7 +163,12 @@
             view,
         };
 
-        let result = self.scheduler.do_scene(&mut junk, scene, &self.paint_idxs);
+        let result = self.scheduler.do_scene(
+            &mut self.scheduler_state,
+            &mut junk,
+            scene,
+            &self.paint_idxs,
+        );
         self.gradient_cache.maintain();
 
         result
diff --git a/sparse_strips/vello_hybrid/src/schedule.rs b/sparse_strips/vello_hybrid/src/schedule.rs
index ebc18d8..cedd44c 100644
--- a/sparse_strips/vello_hybrid/src/schedule.rs
+++ b/sparse_strips/vello_hybrid/src/schedule.rs
@@ -247,12 +247,38 @@
     clear: [Vec<u32>; 2],
 }
 
+/// Reusable state used by the scheduler. We are holding this separately instead of integrating
+/// it into `Scheduler` because it avoids some borrowing issues.
+#[derive(Debug, Default)]
+pub(crate) struct SchedulerState {
+    /// The state of the current wide tile that is being processed.
+    tile_state: TileState,
+    /// Annotated commands for the current wide tile.
+    annotated_commands: Vec<AnnotatedCmd>,
+    /// Pointers to `PushBuf` commands.
+    pointer_to_push_buf_stack: Vec<usize>,
+}
+
+impl SchedulerState {
+    fn clear(&mut self) {
+        self.tile_state.clear();
+        self.annotated_commands.clear();
+        self.pointer_to_push_buf_stack.clear();
+    }
+}
+
 /// State for a single wide tile.
 #[derive(Debug, Default)]
 struct TileState {
     stack: Vec<TileEl>,
 }
 
+impl TileState {
+    fn clear(&mut self) {
+        self.stack.clear();
+    }
+}
+
 /// A claimed slot into one of the two slot textures (0, 1).
 #[derive(Debug, Copy, Clone)]
 enum ClaimedSlot {
@@ -282,9 +308,9 @@
 /// TODO: In the future these annotations could be optionally enabled in coarse.rs directly avoiding
 /// the need to do linear scans.
 #[derive(Debug)]
-enum AnnotatedCmd<'a> {
-    /// A wrapped command - no semantic meaning added.
-    IdentityBorrowed(&'a Cmd),
+enum AnnotatedCmd {
+    /// The index of a wrapped command - no semantic meaning added.
+    Identity(usize),
     PushBuf,
     Empty,
     SrcOverNormalBlend,
@@ -294,18 +320,18 @@
     PushBufWithTemporarySlot,
 }
 
-impl<'a> AnnotatedCmd<'a> {
-    fn as_cmd<'b: 'a>(&'b self) -> Option<&'a Cmd> {
+impl AnnotatedCmd {
+    fn as_cmd<'a>(&self, cmds: &'a [Cmd]) -> Option<&'a Cmd> {
         match self {
-            AnnotatedCmd::IdentityBorrowed(cmd) => Some(cmd),
-            AnnotatedCmd::PushBufWithTemporarySlot => Some(&Cmd::PushBuf(LayerKind::Regular(0))),
-            AnnotatedCmd::PushBuf => Some(&Cmd::PushBuf(LayerKind::Regular(0))),
-            AnnotatedCmd::SrcOverNormalBlend => Some(&Cmd::Blend(BlendMode {
+            Self::Identity(idx) => Some(&cmds[*idx]),
+            Self::PushBufWithTemporarySlot => Some(&Cmd::PushBuf(LayerKind::Regular(0))),
+            Self::PushBuf => Some(&Cmd::PushBuf(LayerKind::Regular(0))),
+            Self::SrcOverNormalBlend => Some(&Cmd::Blend(BlendMode {
                 mix: Mix::Normal,
                 compose: Compose::SrcOver,
             })),
-            AnnotatedCmd::PopBuf => Some(&Cmd::PopBuf),
-            AnnotatedCmd::Empty => None,
+            Self::PopBuf => Some(&Cmd::PopBuf),
+            Self::Empty => None,
         }
     }
 }
@@ -403,6 +429,7 @@
 
     pub(crate) fn do_scene<R: RendererBackend>(
         &mut self,
+        state: &mut SchedulerState,
         renderer: &mut R,
         scene: &Scene,
         paint_idxs: &[u32],
@@ -417,21 +444,24 @@
                 let wide_tile_x = wide_tile_col * WideTile::WIDTH;
                 let wide_tile_y = wide_tile_row * Tile::HEIGHT;
 
-                let tile_state = self.initialize_tile_state(
+                state.clear();
+
+                self.initialize_tile_state(
+                    &mut state.tile_state,
                     wide_tile,
                     wide_tile_x,
                     wide_tile_y,
                     scene,
                     paint_idxs,
                 );
-                let annotated_cmds = prepare_cmds(&wide_tile.cmds);
+                prepare_cmds(&wide_tile.cmds, state);
                 self.do_tile(
+                    state,
                     renderer,
                     scene,
                     wide_tile_x,
                     wide_tile_y,
-                    &annotated_cmds,
-                    tile_state,
+                    &wide_tile.cmds,
                     paint_idxs,
                     &scene.wide.attrs,
                 )?;
@@ -526,16 +556,16 @@
 
     fn initialize_tile_state(
         &mut self,
+        tile_state: &mut TileState,
         tile: &WideTile<MODE_HYBRID>,
         wide_tile_x: u16,
         wide_tile_y: u16,
         scene: &Scene,
         idxs: &[u32],
-    ) -> TileState {
-        let mut state = TileState::default();
+    ) {
         // Sentinel `TileEl` to indicate the end of the stack where we draw all
         // commands to the final target.
-        state.stack.push(TileEl {
+        tile_state.stack.push(TileEl {
             dest_slot: ClaimedSlot::Texture0(SENTINEL_SLOT_IDX),
             temporary_slot: TemporarySlot::None,
             round: self.round,
@@ -559,34 +589,33 @@
                 );
             }
         }
-        state
     }
 
     /// Iterates over wide tile commands and schedules them for rendering.
     ///
     /// Returns `Some(command_idx)` if there is more work to be done. Returns `None` if the wide
     /// tile has been fully consumed.
-    fn do_tile<'a, R: RendererBackend>(
+    fn do_tile<R: RendererBackend>(
         &mut self,
+        state: &mut SchedulerState,
         renderer: &mut R,
         scene: &Scene,
         wide_tile_x: u16,
         wide_tile_y: u16,
-        cmds: &'a [AnnotatedCmd<'a>],
-        mut state: TileState,
+        wide_tile_cmds: &[Cmd],
         paint_idxs: &[u32],
         attrs: &CommandAttrs,
     ) -> Result<(), RenderError> {
-        for annotated_cmd in cmds {
+        for annotated_cmd in &state.annotated_commands {
             // Note: this starts at 1 (for the final target)
-            let depth = state.stack.len();
-            let Some(cmd) = annotated_cmd.as_cmd() else {
+            let depth = state.tile_state.stack.len();
+            let Some(cmd) = annotated_cmd.as_cmd(wide_tile_cmds) else {
                 continue;
             };
 
             match cmd {
                 Cmd::Fill(fill) => {
-                    let el = state.stack.last_mut().unwrap();
+                    let el = state.tile_state.stack.last_mut().unwrap();
                     let draw = self.draw_mut(el.round, el.get_draw_texture(depth));
 
                     let fill_attrs = &attrs.fill[fill.attrs_idx as usize];
@@ -612,7 +641,7 @@
                     draw.push(gpu_strip_builder.paint(payload, paint));
                 }
                 Cmd::AlphaFill(alpha_fill) => {
-                    let el = state.stack.last_mut().unwrap();
+                    let el = state.tile_state.stack.last_mut().unwrap();
                     let draw = self.draw_mut(el.round, el.get_draw_texture(depth));
 
                     let fill_attrs = &attrs.fill[alpha_fill.attrs_idx as usize];
@@ -660,7 +689,7 @@
                     // buffer is being pushed that will be blended back to `tos`, copy contents from
                     // `tos.dest_slot` to `tos.temporary_slot` ready for future blending.
                     {
-                        let tos: &mut TileEl = state.stack.last_mut().unwrap();
+                        let tos: &mut TileEl = state.tile_state.stack.last_mut().unwrap();
                         if let TemporarySlot::Invalid(temp_slot) = tos.temporary_slot {
                             let next_round = depth.is_multiple_of(2);
                             let el_round = tos.round + usize::from(next_round);
@@ -711,7 +740,7 @@
                         } else {
                             TemporarySlot::None
                         };
-                    state.stack.push(TileEl {
+                    state.tile_state.stack.push(TileEl {
                         dest_slot: slot,
                         temporary_slot,
                         round: self.round,
@@ -719,8 +748,8 @@
                     });
                 }
                 Cmd::PopBuf => {
-                    let tos = state.stack.pop().unwrap();
-                    let nos = state.stack.last_mut().unwrap();
+                    let tos = state.tile_state.stack.pop().unwrap();
+                    let nos = state.tile_state.stack.last_mut().unwrap();
                     let next_round = depth.is_multiple_of(2) && depth > 2;
                     let round = nos.round.max(tos.round + usize::from(next_round));
                     nos.round = round;
@@ -743,8 +772,8 @@
                     }
                 }
                 Cmd::ClipFill(clip_fill) => {
-                    let tos: &TileEl = &state.stack[depth - 1];
-                    let nos = &state.stack[depth - 2];
+                    let tos: &TileEl = &state.tile_state.stack[depth - 1];
+                    let nos = &state.tile_state.stack[depth - 2];
 
                     // Basically if we are writing onto the even texture, we need to go up a round
                     // to target it.
@@ -781,12 +810,12 @@
                     };
                     draw.push(gpu_strip_builder.copy_from_slot(tos.dest_slot.get_idx(), 0xFF));
 
-                    let nos_ptr = state.stack.len() - 2;
-                    state.stack[nos_ptr].temporary_slot.invalidate();
+                    let nos_ptr = state.tile_state.stack.len() - 2;
+                    state.tile_state.stack[nos_ptr].temporary_slot.invalidate();
                 }
                 Cmd::ClipStrip(clip_alpha_fill) => {
-                    let tos = &state.stack[depth - 1];
-                    let nos = &state.stack[depth - 2];
+                    let tos = &state.tile_state.stack[depth - 1];
+                    let nos = &state.tile_state.stack[depth - 2];
 
                     let next_round = depth.is_multiple_of(2) && depth > 2;
                     let round = nos.round.max(tos.round + usize::from(next_round));
@@ -831,15 +860,15 @@
                             .with_sparse(clip_alpha_fill.width, col_idx)
                             .copy_from_slot(tos.dest_slot.get_idx(), 0xFF),
                     );
-                    let nos_ptr = state.stack.len() - 2;
-                    state.stack[nos_ptr].temporary_slot.invalidate();
+                    let nos_ptr = state.tile_state.stack.len() - 2;
+                    state.tile_state.stack[nos_ptr].temporary_slot.invalidate();
                 }
                 Cmd::Opacity(opacity) => {
-                    state.stack.last_mut().unwrap().opacity = *opacity;
+                    state.tile_state.stack.last_mut().unwrap().opacity = *opacity;
                 }
                 Cmd::Blend(mode) => {
-                    let tos = state.stack.last().unwrap();
-                    let nos = &state.stack[state.stack.len() - 2];
+                    let tos = state.tile_state.stack.last().unwrap();
+                    let nos = &state.tile_state.stack[state.tile_state.stack.len() - 2];
 
                     let next_round: bool = depth.is_multiple_of(2) && depth > 2;
                     let round = nos.round.max(tos.round + usize::from(next_round));
@@ -871,8 +900,8 @@
                             compose_mode,
                         ));
                         // Invalidate the temporary slot after use
-                        let nos_ptr = state.stack.len() - 2;
-                        state.stack[nos_ptr].temporary_slot.invalidate();
+                        let nos_ptr = state.tile_state.stack.len() - 2;
+                        state.tile_state.stack[nos_ptr].temporary_slot.invalidate();
                     } else {
                         assert_eq!(
                             nos.dest_slot.get_idx(),
@@ -1054,15 +1083,19 @@
 ///
 /// TODO: Can be triggered via a const generic on coarse draw cmd generation which will avoid
 /// a linear scan.
-fn prepare_cmds<'a>(cmds: &'a [Cmd]) -> Vec<AnnotatedCmd<'a>> {
+fn prepare_cmds(cmds: &[Cmd], state: &mut SchedulerState) {
+    let annotated_commands = &mut state.annotated_commands;
+    let pointer_to_push_buf_stack = &mut state.pointer_to_push_buf_stack;
+
     // Reserve room for three extra items such that we can prevent repeated blends into the surface.
-    let mut annotated_commands: Vec<AnnotatedCmd<'a>> = Vec::with_capacity(cmds.len() + 3);
-    let mut pointer_to_push_buf_stack: Vec<usize> = Vec::new();
+    annotated_commands.reserve(cmds.len() + 3);
+
     // We pretend that the surface might be blended into. This will be removed if no blends occur to
     // the surface.
     pointer_to_push_buf_stack.push(0);
     annotated_commands.push(AnnotatedCmd::PushBuf);
-    for cmd in cmds {
+
+    for (cmd_idx, cmd) in cmds.iter().enumerate() {
         match cmd {
             Cmd::PushBuf(_layer_id) => {
                 // TODO: Handle layer_id for filter effects when implemented.
@@ -1084,7 +1117,7 @@
             _ => {}
         };
 
-        annotated_commands.push(AnnotatedCmd::IdentityBorrowed(cmd));
+        annotated_commands.push(AnnotatedCmd::Identity(cmd_idx));
     }
 
     if matches!(
@@ -1099,5 +1132,4 @@
         // This extra wrapping can be removed.
         annotated_commands[0] = AnnotatedCmd::Empty;
     }
-    annotated_commands
 }