gallery/vendor/i-slint-core/graphics/boxshadowcache.rs
Greg Pomerantz 2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00

256 lines
9.6 KiB
Rust

// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
/*!
This module contains a cache helper for caching box shadow textures.
*/
use alloc::boxed::Box;
use std::{
cell::{Cell, RefCell},
collections::BTreeMap,
};
use crate::items::ItemRc;
use crate::{
Color,
lengths::{PhysicalBorderRadius, PhysicalPx, RectLengths, ScaleFactor},
};
/// Struct to store options affecting the rendering of a box shadow
#[derive(Clone, PartialEq, Debug, Default)]
pub struct BoxShadowOptions {
/// The width of the box shadow texture in physical pixels.
pub width: euclid::Length<f32, PhysicalPx>,
/// The height of the box shadow texture in physical pixels.
pub height: euclid::Length<f32, PhysicalPx>,
/// The color for the box shadow.
pub color: Color,
/// The blur to apply to the box shadow in pixels.
pub blur: euclid::Length<f32, PhysicalPx>,
/// The radii of the box shadow.
pub radius: PhysicalBorderRadius,
/// The spread radius in physical pixels. Positive grows the shadow shape, negative shrinks it.
pub spread: euclid::Length<f32, PhysicalPx>,
/// Whether the shadow is rendered inside the element's geometry.
pub inset: bool,
/// Horizontal offset in physical pixels. Only used by inset shadows (drop-shadow offset is
/// applied at blit time and is not part of the cached image).
pub offset_x_inset: f32,
/// Vertical offset in physical pixels. Only used by inset shadows.
pub offset_y_inset: f32,
}
impl Eq for BoxShadowOptions {}
impl Ord for BoxShadowOptions {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
let lhs = (
self.width,
self.height,
self.color,
self.blur,
self.radius.top_left.to_bits(),
self.radius.top_right.to_bits(),
self.radius.bottom_right.to_bits(),
self.radius.bottom_left.to_bits(),
self.spread,
self.inset,
self.offset_x_inset.to_bits(),
self.offset_y_inset.to_bits(),
);
let rhs = (
other.width,
other.height,
other.color,
other.blur,
other.radius.top_left.to_bits(),
other.radius.top_right.to_bits(),
other.radius.bottom_right.to_bits(),
other.radius.bottom_left.to_bits(),
other.spread,
other.inset,
other.offset_x_inset.to_bits(),
other.offset_y_inset.to_bits(),
);
if rhs < lhs {
std::cmp::Ordering::Less
} else if lhs < rhs {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
}
}
impl PartialOrd for BoxShadowOptions {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
/// The geometry of a box shadow, derived from [`BoxShadowOptions`].
///
/// The CSS rules the renderers agree on: the shadow shape is the element's geometry
/// grown by the spread, its corner radii grow with it, and the texture it is rendered
/// into is padded by the blur on every side so the Gaussian can fade out to
/// transparency within it.
impl BoxShadowOptions {
/// The size of the shadow shape: the element's size grown by the spread on each
/// side. A negative spread shrinks it, down to nothing.
pub fn shape_size(&self) -> euclid::Size2D<f32, PhysicalPx> {
euclid::size2(
(self.width.get() + 2. * self.spread.get()).max(0.),
(self.height.get() + 2. * self.spread.get()).max(0.),
)
}
/// The size of the texture a drop shadow is rendered into: the shape padded by the
/// blur on each side.
pub fn drop_texture_size(&self) -> euclid::Size2D<f32, PhysicalPx> {
self.shape_size() + euclid::size2(2. * self.blur.get(), 2. * self.blur.get())
}
/// Where the shape sits within the drop shadow texture, i.e. the blur padding.
pub fn shape_origin(&self) -> euclid::Point2D<f32, PhysicalPx> {
euclid::point2(self.blur.get(), self.blur.get())
}
/// The corner radii of the shadow shape: `max(0, radius + spread)`.
pub fn outer_radius(&self) -> PhysicalBorderRadius {
(self.radius + PhysicalBorderRadius::new_uniform(self.spread.get())).max(Default::default())
}
/// The corner radii of the hole an inset shadow leaves: `max(0, radius - spread)`.
pub fn inner_radius(&self) -> PhysicalBorderRadius {
(self.radius - PhysicalBorderRadius::new_uniform(self.spread.get())).max(Default::default())
}
/// The Gaussian sigma corresponding to the CSS blur radius.
pub fn blur_sigma(&self) -> f32 {
self.blur.get() / 2.
}
/// Extracts the rendering specific properties from the BoxShadow item and scales the logical
/// coordinates to physical pixels used in the BoxShadowOptions. Returns None if for example the
/// alpha on the box shadow would imply that no shadow is to be rendered.
pub fn new(
item_rc: &ItemRc,
box_shadow: std::pin::Pin<&crate::items::BoxShadow>,
scale_factor: ScaleFactor,
) -> Option<Self> {
let color = box_shadow.color();
if color.alpha() == 0 {
return None;
}
let geometry = item_rc.geometry();
let width = geometry.width_length() * scale_factor;
let height = geometry.height_length() * scale_factor;
if width.get() < 1. || height.get() < 1. {
return None;
}
let inset = box_shadow.inset();
let (offset_x_inset, offset_y_inset) = if inset {
(
(box_shadow.offset_x() * scale_factor).get(),
(box_shadow.offset_y() * scale_factor).get(),
)
} else {
(0., 0.)
};
Some(Self {
width,
height,
color,
blur: box_shadow.blur() * scale_factor, // This effectively becomes the blur radius, so scale to physical pixels
radius: box_shadow.logical_border_radius() * scale_factor,
spread: box_shadow.spread() * scale_factor,
inset,
offset_x_inset,
offset_y_inset,
})
}
}
/// Upper bound on the number of shadow textures kept alive by a [`BoxShadowCache`].
const MAX_CACHED_SHADOWS: usize = 16;
struct CacheEntry<ImageType> {
image: Option<ImageType>,
/// Value of the cache's access counter when this entry was last returned, for LRU eviction.
last_used: u64,
}
/// Cache to hold box textures for given box shadow options.
pub struct BoxShadowCache<ImageType> {
entries: RefCell<BTreeMap<BoxShadowOptions, CacheEntry<ImageType>>>,
access_counter: Cell<u64>,
/// Track if the window scale factor changes; used to clear the cache if necessary.
window_scale_factor_tracker: core::pin::Pin<Box<crate::properties::PropertyTracker>>,
}
impl<ImageType> Default for BoxShadowCache<ImageType> {
fn default() -> Self {
Self {
entries: Default::default(),
access_counter: Default::default(),
window_scale_factor_tracker: Box::pin(Default::default()),
}
}
}
impl<ImageType> BoxShadowCache<ImageType> {
/// Removes all cached box shadow textures.
pub fn clear(&self) {
self.entries.borrow_mut().clear();
}
/// Clears the cache if the window's scale factor has changed since the last call, as the
/// cached textures are rendered in physical pixels.
pub fn clear_cache_if_scale_factor_changed(&self, window: &crate::api::Window) {
if self.window_scale_factor_tracker.is_dirty() {
self.window_scale_factor_tracker
.as_ref()
.evaluate_as_dependency_root(|| window.scale_factor());
self.clear();
}
}
}
impl<ImageType: Clone> BoxShadowCache<ImageType> {
/// Look up a box shadow texture for a given box shadow item, or create a new one if needed.
pub fn get_box_shadow(
&self,
item_rc: &ItemRc,
item_cache: &crate::item_rendering::ItemCache<Option<ImageType>>,
box_shadow: std::pin::Pin<&crate::items::BoxShadow>,
scale_factor: ScaleFactor,
shadow_render_fn: impl FnOnce(&BoxShadowOptions) -> Option<ImageType>,
) -> Option<ImageType> {
item_cache.get_or_update_cache_entry(item_rc, || {
let shadow_options = BoxShadowOptions::new(item_rc, box_shadow, scale_factor)?;
let mut entries = self.entries.borrow_mut();
// Shadow options that change on every frame (an animated blur for example) would grow
// the cache without bounds, so evict the least recently used entry when it gets too big.
// Note that evicted images may still be alive through the per-item cache; eviction only
// means that the shadow has to be re-rendered on the next per-item cache miss.
if entries.len() >= MAX_CACHED_SHADOWS
&& !entries.contains_key(&shadow_options)
&& let Some(least_recently_used) = entries
.iter()
.min_by_key(|(_, entry)| entry.last_used)
.map(|(options, _)| options.clone())
{
entries.remove(&least_recently_used);
}
let stamp = self.access_counter.get() + 1;
self.access_counter.set(stamp);
let entry = entries.entry(shadow_options.clone()).or_insert_with(|| CacheEntry {
image: shadow_render_fn(&shadow_options),
last_used: stamp,
});
entry.last_used = stamp;
entry.image.clone()
})
}
}