[vello_hybrid] Add opacity layers (#1122)

### Context

Add opacity handling in `vello_hybrid` layers!

### Discussion

It's probably best to look at the documentation in the shader code, but
this PR leverages the fact that we have a huge amount of bits that do
nothing. I've packed opacity into bits 0-7.

### Test plan

Tested via the existing opacity tests.
diff --git a/sparse_strips/vello_dev_macros/src/test.rs b/sparse_strips/vello_dev_macros/src/test.rs
index 88f94b8..60a13a4 100644
--- a/sparse_strips/vello_dev_macros/src/test.rs
+++ b/sparse_strips/vello_dev_macros/src/test.rs
@@ -163,7 +163,7 @@
     skip_hybrid |= {
         input_fn_name_str.contains("compose")
             || input_fn_name_str.contains("gradient")
-            || input_fn_name_str.contains("layer")
+            || input_fn_name_str.contains("layer_multiple_properties")
             || input_fn_name_str.contains("mask")
             || input_fn_name_str.contains("mix")
             || input_fn_name_str.contains("blurred_rounded_rect")
diff --git a/sparse_strips/vello_hybrid/src/scene.rs b/sparse_strips/vello_hybrid/src/scene.rs
index 38247f2..e99ea52 100644
--- a/sparse_strips/vello_hybrid/src/scene.rs
+++ b/sparse_strips/vello_hybrid/src/scene.rs
@@ -183,9 +183,6 @@
         if blend_mode.is_some() {
             unimplemented!()
         }
-        if opacity.is_some() {
-            unimplemented!()
-        }
         if mask.is_some() {
             unimplemented!()
         }
@@ -194,7 +191,7 @@
             clip,
             BlendMode::new(Mix::Normal, Compose::SrcOver),
             None,
-            1.0,
+            opacity.unwrap_or(1.),
             0,
         );
     }
diff --git a/sparse_strips/vello_hybrid/src/schedule.rs b/sparse_strips/vello_hybrid/src/schedule.rs
index 42928e7..3d5665b 100644
--- a/sparse_strips/vello_hybrid/src/schedule.rs
+++ b/sparse_strips/vello_hybrid/src/schedule.rs
@@ -181,6 +181,7 @@
 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,
@@ -244,6 +245,7 @@
 struct TileEl {
     slot_ix: usize,
     round: usize,
+    opacity: f32,
 }
 
 #[derive(Debug, Default)]
@@ -377,6 +379,7 @@
         state.stack.push(TileEl {
             slot_ix: usize::MAX,
             round: self.round,
+            opacity: 1.,
         });
         {
             // If the background has a non-zero alpha then we need to render it.
@@ -466,6 +469,7 @@
                     state.stack.push(TileEl {
                         slot_ix,
                         round: self.round,
+                        opacity: 1.,
                     });
                 }
                 Cmd::PopBuf => {
@@ -494,7 +498,8 @@
                     } else {
                         (clip_fill.x as u16, nos.slot_ix as u16 * Tile::HEIGHT)
                     };
-                    let paint = COLOR_SOURCE_SLOT << 31;
+                    // Opacity packed into the first 8 bits – pack full opacity (0xFF).
+                    let paint = COLOR_SOURCE_SLOT << 31 | 0xFF;
                     draw.0.push(GpuStrip {
                         x,
                         y,
@@ -516,7 +521,8 @@
                     } else {
                         (clip_alpha_fill.x as u16, nos.slot_ix as u16 * Tile::HEIGHT)
                     };
-                    let paint = COLOR_SOURCE_SLOT << 31;
+                    // Opacity packed into the first 8 bits – pack full opacity (0xFF).
+                    let paint = COLOR_SOURCE_SLOT << 31 | 0xFF;
                     draw.0.push(GpuStrip {
                         x,
                         y,
@@ -529,6 +535,49 @@
                         paint,
                     });
                 }
+                Cmd::Opacity(opacity) => {
+                    state.stack.last_mut().unwrap().opacity = *opacity;
+                }
+                Cmd::Blend(mode) => {
+                    // This blend mode is implicitly supported. Currently no other blend mode is
+                    // supported in `vello_hybrid`.
+                    assert!(
+                        matches!(
+                            mode,
+                            BlendMode {
+                                mix: Mix::Normal,
+                                compose: Compose::SrcOver
+                            }
+                        ),
+                        "Changing blend mode is unsupported"
+                    );
+
+                    let tos = state.stack.last().unwrap();
+                    let nos = &state.stack[state.stack.len() - 2];
+
+                    let next_round = clip_depth % 2 == 0 && clip_depth > 2;
+                    let round = nos.round.max(tos.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, nos.slot_ix as u16 * Tile::HEIGHT)
+                    };
+
+                    // Opacity packed into the first 8 bits.
+                    let opacity_u8 = (tos.opacity * 255.0) as u32;
+                    let paint = (COLOR_SOURCE_SLOT << 31) | opacity_u8;
+
+                    draw.0.push(GpuStrip {
+                        x,
+                        y,
+                        width: WideTile::WIDTH,
+                        dense_width: 0,
+                        col_idx: 0,
+                        payload: tos.slot_ix as u32,
+                        paint,
+                    });
+                }
                 _ => unimplemented!(),
             }
         }
diff --git a/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl b/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
index 9410033..1d2bc1b 100644
--- a/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
+++ b/sparse_strips/vello_sparse_shaders/shaders/render_strips.wgsl
@@ -59,8 +59,10 @@
 //
 // `paint` bit layout:
 //   - Bit 31:     `color_source`      0 = use payload, 1 = use slot texture
-//   - Bits 29-30: `paint_type`        0 = solid, 1 = image
-//   - Bits 0-28:  `paint_texture_id`  if paint_type = PAINT_TYPE_IMAGE, index of `EncodedImage` 
+//   - Bits 29-30: `paint_type`        0 = solid, 1 = image (only used when color_source = 0)
+//   - Bits 0-28:  Usage depends on color_source:
+//                 - When color_source = 0 and paint_type = 1: `paint_texture_id` (index of `EncodedImage`)
+//                 - When color_source = 1: bits 0-7 contain opacity (0-255)
 //
 // Decision tree for paint/payload interpretation:
 //
@@ -69,10 +71,12 @@
 // │   └── payload = [r, g, b, a] RGBA (packed as u8s)
 // │
 // └── paint_type = 1 (PAINT_TYPE_IMAGE) - Image rendering
-//     └── payload = [x, y] scene coordinates (packed as u16s)
+//     ├── payload = [x, y] scene coordinates (packed as u16s)
+//     └── bits 0-28 = paint_texture_id
 //
 // color_source = 1 (COLOR_SOURCE_SLOT) - Use slot texture
-// └── payload = slot_index (u32)
+// ├── payload = slot_index (u32)
+// └── bits 0-7 = opacity (0-255, where 255 = fully opaque)
 struct StripInstance {
     // [x, y] packed as u16's
     // x, y — coordinates of the strip
@@ -258,7 +262,11 @@
         let clip_x = u32(in.position.x) & 0xFFu;
         let clip_y = (u32(in.position.y) & 3) + in.payload * config.strip_height;
         let clip_in_color = textureLoad(clip_input_texture, vec2(clip_x, clip_y), 0);
-        final_color = alpha * clip_in_color;
+
+        // Extract opacity from first 8 bits (quantized from [0, 255])
+        let opacity = f32(in.paint & 0xFFu) * (1.0 / 255.0);
+
+        final_color = alpha * opacity * clip_in_color;
     }
 
     return final_color;
diff --git a/sparse_strips/vello_sparse_tests/snapshots/image_with_opacity.png b/sparse_strips/vello_sparse_tests/snapshots/image_with_opacity.png
new file mode 100644
index 0000000..e8991cc
--- /dev/null
+++ b/sparse_strips/vello_sparse_tests/snapshots/image_with_opacity.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:001350ccad11b3120dbf2b44350734ac145c5a41e277c281a5da2969e4e13001
+size 152
diff --git a/sparse_strips/vello_sparse_tests/tests/image.rs b/sparse_strips/vello_sparse_tests/tests/image.rs
index caba16c..2798454 100644
--- a/sparse_strips/vello_sparse_tests/tests/image.rs
+++ b/sparse_strips/vello_sparse_tests/tests/image.rs
@@ -7,6 +7,7 @@
 use crate::util::crossed_line_star;
 use std::f64::consts::PI;
 use std::sync::Arc;
+use vello_common::color::palette::css::REBECCA_PURPLE;
 use vello_common::kurbo::{Affine, Point, Rect};
 use vello_common::paint::{Image, ImageSource};
 use vello_common::peniko::{Extend, ImageQuality};
@@ -252,6 +253,29 @@
     ctx.fill_rect(&rect);
 }
 
+#[vello_test]
+fn image_with_opacity(ctx: &mut impl Renderer) {
+    ctx.set_paint(REBECCA_PURPLE);
+    ctx.fill_rect(&Rect::new(0.0, 0.0, 100.0, 100.0));
+
+    ctx.push_opacity_layer(0.5);
+
+    let rect = Rect::new(10.0, 10.0, 90.0, 90.0);
+    let image_source = rgb_img_10x10(ctx);
+
+    let image = Image {
+        source: image_source,
+        x_extend: Extend::Repeat,
+        y_extend: Extend::Repeat,
+        quality: ImageQuality::Low,
+    };
+
+    ctx.set_paint(image);
+    ctx.fill_rect(&rect);
+
+    ctx.pop_layer();
+}
+
 fn image_format(ctx: &mut impl Renderer, image_source: ImageSource) {
     let rect = Rect::new(10.0, 10.0, 90.0, 90.0);
 
diff --git a/sparse_strips/vello_sparse_tests/tests/renderer.rs b/sparse_strips/vello_sparse_tests/tests/renderer.rs
index b6c8e8c..c7992f8 100644
--- a/sparse_strips/vello_sparse_tests/tests/renderer.rs
+++ b/sparse_strips/vello_sparse_tests/tests/renderer.rs
@@ -274,8 +274,8 @@
         unimplemented!()
     }
 
-    fn push_opacity_layer(&mut self, _: f32) {
-        unimplemented!()
+    fn push_opacity_layer(&mut self, opacity: f32) {
+        self.scene.push_layer(None, None, Some(opacity), None);
     }
 
     fn push_mask_layer(&mut self, _: Mask) {
@@ -542,8 +542,8 @@
         unimplemented!()
     }
 
-    fn push_opacity_layer(&mut self, _: f32) {
-        unimplemented!()
+    fn push_opacity_layer(&mut self, opacity: f32) {
+        self.scene.push_layer(None, None, Some(opacity), None);
     }
 
     fn push_mask_layer(&mut self, _: Mask) {