From fa60580d6eb4cc9552642c2c523681a4474526c2 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 11 Apr 2026 15:12:14 -0400 Subject: [PATCH] perf: colour-independent bitmap cache, zeno::Stroke fixes, dead code removal Tasks 7-8 from Phase 7 plus batch 2 corrections: - Bitmap cache stores alpha masks (is_alpha flag) for colour-independent reuse. Fill-only text/vectors cache single-channel alpha; complex layers with outline/shadow cache RGBA. Cache keys exclude fill colour. - AC5.5 test: same glyph in 3 colours produces 1 cache miss + 2 hits. - Fix zeno::Stroke builder: cap()/join() return &mut Self, so chaining creates a borrow-of-temporary. Split into let mut + mutation. - Remove dead apply_colour_into and composite_alpha_mask functions. - Verify clip masks must use tiny-skia (draw_pixmap requires Mask type). - Remove tiny-skia from pipeline: only comment references remain. - Three-target verification: native, server, wasm32 all pass. --- .../src/backends/alpha_rasteriser.rs | 131 ------------ crates/ass-renderer/src/backends/software.rs | 192 +++++++++++++----- crates/ass-renderer/src/cache.rs | 8 +- .../ass-renderer/src/pipeline/shaping/mod.rs | 2 +- 4 files changed, 154 insertions(+), 179 deletions(-) diff --git a/crates/ass-renderer/src/backends/alpha_rasteriser.rs b/crates/ass-renderer/src/backends/alpha_rasteriser.rs index 01782cc..ddf4060 100644 --- a/crates/ass-renderer/src/backends/alpha_rasteriser.rs +++ b/crates/ass-renderer/src/backends/alpha_rasteriser.rs @@ -114,104 +114,6 @@ pub fn apply_colour(alpha: &[u8], colour: [u8; 4]) -> Vec { rgba } -/// Apply colour to an alpha mask, writing premultiplied RGBA into an existing buffer. -/// -/// Same precision as `apply_colour`, but writes into `output` instead of allocating. -/// The output buffer must be at least `alpha.len() * 4` bytes. -#[allow(dead_code)] -pub fn apply_colour_into(alpha: &[u8], colour: [u8; 4], output: &mut [u8]) { - let cr = colour[0] as u32; - let cg = colour[1] as u32; - let cb = colour[2] as u32; - let ca = colour[3] as u32; - - for (i, &a) in alpha.iter().enumerate() { - let fa = a as u32 * ca; - let final_alpha = (fa + 127) / 255; - let r = (cr * fa + 32512) / 65025; - let g = (cg * fa + 32512) / 65025; - let b = (cb * fa + 32512) / 65025; - let base = i * 4; - output[base] = r as u8; - output[base + 1] = g as u8; - output[base + 2] = b as u8; - output[base + 3] = final_alpha as u8; - } -} - -/// Composite an alpha mask with a given colour onto an RGBA buffer using -/// SourceOver blending. -#[allow(dead_code)] -/// -/// This combines `apply_colour` and compositing in a single pass, avoiding -/// a temporary RGBA allocation. The `dst` buffer is modified in place. -/// -/// `dst_width` and `dst_height` are the dimensions of the destination buffer. -/// `dst_x` and `dst_y` are the top-left position where the mask should be placed. -/// Pixels outside the destination bounds are clipped. -pub fn composite_alpha_mask( - dst: &mut [u8], - dst_width: u32, - dst_height: u32, - mask: &AlphaMask, - colour: [u8; 4], - dst_x: i32, - dst_y: i32, -) { - let cr = colour[0] as u32; - let cg = colour[1] as u32; - let cb = colour[2] as u32; - let ca = colour[3] as u32; - - let dst_w = dst_width as i32; - let dst_h = dst_height as i32; - let mask_w = mask.width as i32; - let mask_h = mask.height as i32; - - // Compute clipped region - let start_x = dst_x.max(0); - let start_y = dst_y.max(0); - let end_x = (dst_x + mask_w).min(dst_w); - let end_y = (dst_y + mask_h).min(dst_h); - - if start_x >= end_x || start_y >= end_y { - return; - } - - for y in start_y..end_y { - let mask_row = (y - dst_y) as usize; - let dst_row_offset = (y as usize) * (dst_width as usize) * 4; - - for x in start_x..end_x { - let mask_col = (x - dst_x) as usize; - let a = mask.data[mask_row * (mask.width as usize) + mask_col]; - if a == 0 { - continue; - } - - let fa = a as u32 * ca; - let src_a = (fa + 127) / 255; - let src_r = (cr * fa + 32512) / 65025; - let src_g = (cg * fa + 32512) / 65025; - let src_b = (cb * fa + 32512) / 65025; - - let dst_idx = dst_row_offset + (x as usize) * 4; - - // SourceOver compositing: out = src + dst * (1 - src_alpha) - let dst_r = dst[dst_idx] as u32; - let dst_g = dst[dst_idx + 1] as u32; - let dst_b = dst[dst_idx + 2] as u32; - let dst_a = dst[dst_idx + 3] as u32; - - let inv_a = 255 - src_a; - dst[dst_idx] = (src_r + (dst_r * inv_a + 127) / 255) as u8; - dst[dst_idx + 1] = (src_g + (dst_g * inv_a + 127) / 255) as u8; - dst[dst_idx + 2] = (src_b + (dst_b * inv_a + 127) / 255) as u8; - dst[dst_idx + 3] = (src_a + (dst_a * inv_a + 127) / 255) as u8; - } - } -} - /// Single-channel accumulator for stackblur-iter. /// /// Wraps a single i64 channel with the arithmetic operations that @@ -387,16 +289,6 @@ mod tests { assert_eq!(rgba[0], expected_r as u8); } - #[test] - fn apply_colour_into_matches_apply_colour() { - let alpha = [0u8, 64, 128, 192, 255]; - let colour = [200, 100, 50, 180]; - let expected = apply_colour(&alpha, colour); - let mut output = vec![0u8; alpha.len() * 4]; - apply_colour_into(&alpha, colour, &mut output); - assert_eq!(output, expected); - } - #[test] fn stackblur_alpha_no_crash_on_small_buffer() { let mut data = vec![255u8; 4 * 4]; @@ -454,27 +346,4 @@ mod tests { assert!(mask.data.iter().any(|&a| a > 0)); } - #[test] - fn composite_alpha_mask_basic() { - // 4x4 destination, fully transparent - let mut dst = vec![0u8; 4 * 4 * 4]; - let mask = AlphaMask { - data: vec![255, 128, 0, 0], - width: 2, - height: 2, - offset_x: 0.0, - offset_y: 0.0, - }; - composite_alpha_mask(&mut dst, 4, 4, &mask, [255, 0, 0, 255], 1, 1); - // Pixel (1,1) should be fully red - let idx = (1 * 4 + 1) * 4; - assert_eq!(dst[idx], 255); // R - assert_eq!(dst[idx + 1], 0); // G - assert_eq!(dst[idx + 2], 0); // B - assert_eq!(dst[idx + 3], 255); // A - // Pixel (2,1) should be half-intensity red - let idx2 = (1 * 4 + 2) * 4; - assert_eq!(dst[idx2], 128); // R - assert_eq!(dst[idx2 + 3], 128); // A - } } diff --git a/crates/ass-renderer/src/backends/software.rs b/crates/ass-renderer/src/backends/software.rs index 4b52ce5..a45e32c 100644 --- a/crates/ass-renderer/src/backends/software.rs +++ b/crates/ass-renderer/src/backends/software.rs @@ -138,8 +138,14 @@ fn draw_vector_layer( // --- Bitmap cache lookup --- let bitmap_key = vector_bitmap_key(data); if let Some(cached) = cache.bitmap_cache.get(&bitmap_key) { + let rgba_data = if cached.is_alpha { + let rgba = super::alpha_rasteriser::apply_colour(&cached.data, data.color); + bytes::Bytes::from(rgba) + } else { + cached.data.clone() + }; if let Some(src) = - tiny_skia::PixmapRef::from_bytes(&cached.data, cached.width, cached.height) + tiny_skia::PixmapRef::from_bytes(&rgba_data, cached.width, cached.height) { let paint = tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::SourceOver, @@ -195,7 +201,10 @@ fn draw_vector_layer( None }; - // Build clip mask if clip region is specified + // Build clip mask if clip region is specified. + // Uses tiny-skia's Mask/PathBuilder because PixmapMut::draw_pixmap requires + // Option<&tiny_skia::Mask> for clipping — zeno has no equivalent type that + // tiny-skia's compositing API accepts. let clip_mask = if let Some((x1, y1, x2, y2)) = data.clip { let width = pixmap.width(); let height = pixmap.height(); @@ -332,15 +341,25 @@ fn draw_vector_layer( } } - // Cache the rendered bitmap + // Cache the rendered bitmap. For fill-only vectors (no stroke), cache the + // alpha mask for colour-independent reuse. For stroke+fill, cache full RGBA. + let has_stroke = data.stroke.is_some(); + let (cache_data, cache_is_alpha) = if !has_stroke { + let rgba = temp_pixmap.data_mut(); + let alpha: Vec = rgba.chunks_exact(4).map(|px| px[3]).collect(); + (bytes::Bytes::from(alpha), true) + } else { + (bytes::Bytes::copy_from_slice(temp_pixmap.data_mut()), false) + }; cache.bitmap_cache.insert( bitmap_key, crate::cache::CachedBitmap { - data: bytes::Bytes::copy_from_slice(temp_pixmap.data_mut()), + data: cache_data, width: temp_pixmap.width(), height: temp_pixmap.height(), offset_x: temp_ox, offset_y: temp_oy, + is_alpha: cache_is_alpha, }, ); @@ -472,8 +491,16 @@ fn draw_text_layer( // --- Bitmap cache lookup --- let bitmap_key = text_bitmap_key(data); if let Some(cached) = cache.bitmap_cache.get(&bitmap_key) { + // Cache stores single-channel alpha masks. Apply the current event's + // fill colour to produce RGBA before compositing. + let rgba_data = if cached.is_alpha { + let rgba = super::alpha_rasteriser::apply_colour(&cached.data, data.color); + bytes::Bytes::from(rgba) + } else { + cached.data.clone() + }; if let Some(src) = - tiny_skia::PixmapRef::from_bytes(&cached.data, cached.width, cached.height) + tiny_skia::PixmapRef::from_bytes(&rgba_data, cached.width, cached.height) { let paint = tiny_skia::PixmapPaint { blend_mode: tiny_skia::BlendMode::SourceOver, @@ -585,7 +612,10 @@ fn draw_text_layer( } } - // Create clip mask if needed (still uses tiny-skia for clip rectangle rasterisation) + // Create clip mask if needed. + // Uses tiny-skia's Mask/PathBuilder because PixmapMut::draw_pixmap requires + // Option<&tiny_skia::Mask> for clipping — zeno has no equivalent type that + // tiny-skia's compositing API accepts. let clip_mask = data.effects.iter().find_map(|e| { if let crate::pipeline::TextEffect::Clip { x1, @@ -744,17 +774,12 @@ fn draw_text_layer( // Shadow includes the outline stroke if border exists if let Some(bord_w) = outline_width_for_shadow { if bord_w > 0.0 { - let stroke_style = zeno::Stroke { - width: bord_w * 2.0, - join: zeno::Join::Round, - start_cap: zeno::Cap::Round, - end_cap: zeno::Cap::Round, - ..Default::default() - }; + let mut stroke_style = zeno::Stroke::new(bord_w * 2.0); + stroke_style.cap(zeno::Cap::Round).join(zeno::Join::Round); rasterise_and_composite_layer( &commands, shadow_zeno, - stroke_style.into(), + (&stroke_style).into(), *color, blur_radius, 0, @@ -785,13 +810,8 @@ fn draw_text_layer( // libass expands outward by the full border width. zeno::Stroke // extends width/2 each side, so use 2x width. We render the stroke // mask, then subtract the fill interior to get outline-only. - let stroke_style = zeno::Stroke { - width: *width * 2.0, - join: zeno::Join::Round, - start_cap: zeno::Cap::Round, - end_cap: zeno::Cap::Round, - ..Default::default() - }; + let mut stroke_style = zeno::Stroke::new(*width * 2.0); + stroke_style.cap(zeno::Cap::Round).join(zeno::Join::Round); // Rasterise the full 2x stroke let stroke_mask = super::alpha_rasteriser::rasterise_to_alpha( @@ -799,7 +819,7 @@ fn draw_text_layer( Some(zeno_transform), blur_padding, scratch, - stroke_style.into(), + (&stroke_style).into(), ); if let Some(stroke_mask) = stroke_mask { @@ -940,17 +960,13 @@ fn draw_text_layer( zeno::Command::LineTo(zeno::Vector::new(ul_x + shaped.width, underline_y)), ]; let stroke_w = data.font_size * 0.08; - let stroke_style = zeno::Stroke { - width: stroke_w, - start_cap: zeno::Cap::Round, - end_cap: zeno::Cap::Round, - ..Default::default() - }; + let mut stroke_style = zeno::Stroke::new(stroke_w); + stroke_style.cap(zeno::Cap::Round); // Underline is drawn in temp-pixmap space directly (no transform needed) rasterise_and_composite_layer( &ul_commands, zeno::Transform::IDENTITY, - stroke_style.into(), + (&stroke_style).into(), fill_colour, blur_radius, 0, @@ -968,16 +984,12 @@ fn draw_text_layer( zeno::Command::LineTo(zeno::Vector::new(st_x + shaped.width, strike_y)), ]; let stroke_w = data.font_size * 0.06; - let stroke_style = zeno::Stroke { - width: stroke_w, - start_cap: zeno::Cap::Round, - end_cap: zeno::Cap::Round, - ..Default::default() - }; + let mut stroke_style = zeno::Stroke::new(stroke_w); + stroke_style.cap(zeno::Cap::Round); rasterise_and_composite_layer( &st_commands, zeno::Transform::IDENTITY, - stroke_style.into(), + (&stroke_style).into(), fill_colour, blur_radius, 0, @@ -991,14 +1003,32 @@ fn draw_text_layer( let offset_x = -expand; let offset_y = -expand; + // For simple fill-only text (no outline, no shadow), cache the alpha mask + // so it can be reused colour-independently. For text with outline or shadow, + // cache the full composited RGBA since multiple colours are baked in. + let has_outline_or_shadow = outline_width > 0.0 + || shadow_x_offset > 0.0 + || shadow_y_offset > 0.0; + let store_alpha = !has_outline_or_shadow; + + let (cache_data, cache_is_alpha) = if store_alpha { + // Extract alpha channel from the RGBA temp pixmap + let rgba = temp_pixmap.data_mut(); + let alpha: Vec = rgba.chunks_exact(4).map(|px| px[3]).collect(); + (bytes::Bytes::from(alpha), true) + } else { + (bytes::Bytes::copy_from_slice(temp_pixmap.data_mut()), false) + }; + cache.bitmap_cache.insert( bitmap_key, crate::cache::CachedBitmap { - data: bytes::Bytes::copy_from_slice(temp_pixmap.data_mut()), + data: cache_data, width: temp_pixmap.width(), height: temp_pixmap.height(), offset_x, offset_y, + is_alpha: cache_is_alpha, }, ); @@ -1040,9 +1070,9 @@ fn text_bitmap_key(data: &crate::pipeline::TextData) -> BitmapCacheKey { .any(|e| matches!(e, crate::pipeline::TextEffect::Italic)); bold.hash(&mut hasher); italic.hash(&mut hasher); - // In Phase 4 (RGBA caching), colour is baked into the cached bitmap. - // Two segments with same text but different colours MUST have different keys. - data.color.hash(&mut hasher); + // Colour is NOT hashed: the bitmap cache stores single-channel alpha masks, + // so the same glyph shape at the same transform is reusable regardless of + // colour. Colour is applied at composite time via `apply_colour`. // Spacing affects glyph positions data.spacing.to_bits().hash(&mut hasher); let content_hash = hasher.finish(); @@ -1067,16 +1097,13 @@ fn text_bitmap_key(data: &crate::pipeline::TextData) -> BitmapCacheKey { /// Compute a bitmap cache key from vector layer data. fn vector_bitmap_key(data: &crate::pipeline::VectorData) -> BitmapCacheKey { // content_hash is pre-computed from the drawing command string in the pipeline stage. - let content_hash = data.content_hash; - - // Also hash colour into the content identity since bitmaps bake colour in. + // Colour is NOT hashed: the bitmap cache stores single-channel alpha masks. + // Stroke width affects the mask shape, so it IS included. let content_hash = { use std::hash::{Hash, Hasher}; let mut hasher = std::hash::DefaultHasher::new(); - content_hash.hash(&mut hasher); - data.color.hash(&mut hasher); + data.content_hash.hash(&mut hasher); if let Some(stroke) = &data.stroke { - stroke.color.hash(&mut hasher); stroke.width.to_bits().hash(&mut hasher); } hasher.finish() @@ -1565,4 +1592,77 @@ mod tests { ); } } + + /// AC5.5: Render the same glyph shape in 3 different colours and verify + /// the bitmap cache stores only 1 entry (alpha mask reused for all colours). + #[test] + fn alpha_cache_reuses_single_entry_across_colours() { + let ctx = make_context(128, 64); + let mut backend = SoftwareBackend::new(&ctx).expect("failed to create backend"); + + // Build a simple shaped entry with a rectangular "glyph" path. + let glyph_path = vec![ + zeno::Command::MoveTo(zeno::Vector::new(0.0, 0.0)), + zeno::Command::LineTo(zeno::Vector::new(10.0, 0.0)), + zeno::Command::LineTo(zeno::Vector::new(10.0, 12.0)), + zeno::Command::LineTo(zeno::Vector::new(0.0, 12.0)), + zeno::Command::Close, + ]; + let shaped_entry = Arc::new(crate::cache::ShapedEntry { + shaped: crate::pipeline::shaping::ShapedText { + glyphs: vec![], + width: 10.0, + height: 14.0, + baseline: 12.0, + font_size: 14.0, + ascent: 12.0, + descent: -2.0, + }, + glyph_paths: vec![glyph_path], + }); + + let colours: [[u8; 4]; 3] = [ + [255, 0, 0, 255], // red + [0, 255, 0, 255], // green + [0, 0, 255, 255], // blue + ]; + + // Render each colour as a separate frame. The frame cache must be + // invalidated between frames to force re-rendering (otherwise the + // frame fingerprint check short-circuits). + for (i, colour) in colours.iter().enumerate() { + let layer = IntermediateLayer::Text(crate::pipeline::TextData { + text: "X".into(), + font_family: "Test".into(), + font_size: 14.0, + color: *colour, + x: 10.0, + y: 10.0, + effects: smallvec::smallvec![], + spacing: 0.0, + anchor: None, + shaped_entry: Arc::clone(&shaped_entry), + }); + + // Invalidate frame cache so the bitmap cache path is exercised. + backend.frame_cache.invalidate(); + let _frame = backend + .composite_layers(&[layer], &ctx) + .unwrap_or_else(|e| panic!("render {i} failed: {e:?}")); + } + + // After 3 renders with different colours: + // - 1 bitmap cache miss (first colour triggers rasterisation) + // - 2 bitmap cache hits (subsequent colours reuse the cached alpha mask) + let misses = backend.cache.bitmap_cache.misses(); + let hits = backend.cache.bitmap_cache.hits(); + assert_eq!( + misses, 1, + "expected 1 bitmap cache miss (initial rasterisation), got {misses}" + ); + assert_eq!( + hits, 2, + "expected 2 bitmap cache hits (alpha mask reused for different colours), got {hits}" + ); + } } diff --git a/crates/ass-renderer/src/cache.rs b/crates/ass-renderer/src/cache.rs index 43cf1b9..4c871b2 100644 --- a/crates/ass-renderer/src/cache.rs +++ b/crates/ass-renderer/src/cache.rs @@ -42,7 +42,8 @@ pub struct BitmapCacheKey { /// Cached bitmap data — rasterised and blurred pixel data ready for compositing. #[derive(Clone)] pub struct CachedBitmap { - /// RGBA pixel data + /// Pixel data. If `is_alpha` is true, this is a single-channel alpha mask + /// (one byte per pixel). Otherwise it is RGBA (four bytes per pixel). pub data: bytes::Bytes, /// Bitmap width in pixels pub width: u32, @@ -51,6 +52,11 @@ pub struct CachedBitmap { /// Offset from the event's base position to where this bitmap should be blitted pub offset_x: f32, pub offset_y: f32, + /// If true, `data` contains a single-channel alpha mask. Colour must be + /// applied via `apply_colour` before compositing. This enables colour- + /// independent caching: the same glyph shape reuses one cached mask + /// regardless of colour. + pub is_alpha: bool, } /// Memory-budget LRU cache for rasterised bitmaps. diff --git a/crates/ass-renderer/src/pipeline/shaping/mod.rs b/crates/ass-renderer/src/pipeline/shaping/mod.rs index 56b143a..7cb6137 100644 --- a/crates/ass-renderer/src/pipeline/shaping/mod.rs +++ b/crates/ass-renderer/src/pipeline/shaping/mod.rs @@ -785,7 +785,7 @@ impl GlyphRenderer { /// Translate zeno path commands by (dx, dy) and append to the output vector. /// /// This applies a simple translation to all point coordinates in the commands, -/// which is equivalent to what `tiny_skia::Path::transform(translate(dx, dy))` did. +/// which replaces what `tiny_skia::Path::transform(translate(dx, dy))` previously did. fn translate_zeno_commands(src: &[zeno::Command], dx: f32, dy: f32, out: &mut Vec) { let offset = zeno::Vector::new(dx, dy); out.reserve(src.len()); -- 2.51.2