This commit is contained in:
Alejandro Alonso 2026-04-14 14:04:41 +02:00
parent 7989625fd2
commit 836f7b7481

View File

@ -15,7 +15,7 @@ mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
use std::borrow::Cow;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet, VecDeque};
use gpu_state::GpuState;
@ -37,6 +37,69 @@ use crate::wapi;
pub use fonts::*;
pub use images::*;
#[derive(Clone)]
struct CachedThumbnail {
image: skia::Image,
/// Document-space rectangle that the thumbnail should be drawn into.
doc_rect: Rect,
/// Scale of the view at capture time (best-effort heuristic/debug).
capture_scale: f32,
}
struct ThumbnailCache {
max_entries: usize,
// LRU bookkeeping: newest at the back
lru: VecDeque<Uuid>,
map: HashMap<Uuid, CachedThumbnail>,
}
impl ThumbnailCache {
fn new(max_entries: usize) -> Self {
Self {
max_entries,
lru: VecDeque::new(),
map: HashMap::new(),
}
}
fn get(&mut self, id: &Uuid) -> Option<&CachedThumbnail> {
if self.map.contains_key(id) {
if let Some(pos) = self.lru.iter().position(|x| x == id) {
self.lru.remove(pos);
}
self.lru.push_back(*id);
}
self.map.get(id)
}
fn insert(&mut self, id: Uuid, value: CachedThumbnail) {
if self.map.contains_key(&id) {
self.map.insert(id, value);
if let Some(pos) = self.lru.iter().position(|x| x == &id) {
self.lru.remove(pos);
}
self.lru.push_back(id);
return;
}
self.map.insert(id, value);
self.lru.push_back(id);
while self.map.len() > self.max_entries {
if let Some(evicted) = self.lru.pop_front() {
self.map.remove(&evicted);
} else {
break;
}
}
}
#[allow(dead_code)]
fn len(&self) -> usize {
self.map.len()
}
}
// This is the extra area used for tile rendering (tiles beyond viewport).
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
@ -341,6 +404,12 @@ pub(crate) struct RenderState {
/// Cleared at the beginning of a render pass; set to true after we clear Cache the first
/// time we are about to blit a tile into Cache for this pass.
pub cache_cleared_this_render: bool,
/// Best-effort in-memory thumbnails for top-level containers.
/// Phase 1: no invalidation; populated lazily when a thumbnail was requested.
thumbnails: ThumbnailCache,
/// Eligible container ids for which we want to capture a thumbnail when they finish rendering.
requested_thumbnails: HashSet<Uuid>,
}
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
@ -414,9 +483,98 @@ impl RenderState {
preview_mode: false,
export_context: None,
cache_cleared_this_render: false,
thumbnails: ThumbnailCache::new(256),
requested_thumbnails: HashSet::default(),
})
}
#[inline]
fn thumb_lod_mode(&self, scale: f32) -> bool {
true
// Phase 1 heuristic:
// - Always allow thumbnails in fast mode (pan/zoom in progress).
// - Also allow at very low zoom to reduce work.
// const THUMB_ZOOM_THRESHOLD: f32 = 0.22;
// self.options.is_fast_mode() || scale <= THUMB_ZOOM_THRESHOLD
}
#[inline]
fn thumb_is_eligible(shape: &Shape) -> bool {
// Phase 1 heuristic: top-level containers only.
// This covers root frames and (best-effort) top-level groups.
if shape.parent_id != Some(Uuid::nil()) {
return false;
}
matches!(shape.shape_type, Type::Frame(_) | Type::Group(_))
}
fn doc_rect_to_surface_irect(&mut self, doc_rect: Rect, scale: f32) -> skia::IRect {
let (tx, ty) = self
.surfaces
.get_render_context_translation(self.render_area, scale);
let left = ((doc_rect.left() + tx) * scale).floor() as i32;
let top = ((doc_rect.top() + ty) * scale).floor() as i32;
let right = ((doc_rect.right() + tx) * scale).ceil() as i32;
let bottom = ((doc_rect.bottom() + ty) * scale).ceil() as i32;
skia::IRect::new(left, top, right, bottom)
}
fn maybe_draw_thumbnail(&mut self, element: &Shape, tree: ShapesPoolRef, scale: f32) -> bool {
if !Self::thumb_is_eligible(element) || !self.thumb_lod_mode(scale) {
return false;
}
if let Some(cached) = self.thumbnails.get(&element.id) {
// Draw into fills; other passes stay empty. The fills surface is already
// transformed to document coordinates (scale+translate).
let canvas = self.surfaces.canvas_and_mark_dirty(SurfaceId::Fills);
canvas.draw_image_rect_with_sampling_options(
&cached.image,
None,
cached.doc_rect,
self.sampling_options,
&skia::Paint::default(),
);
// Composite the fills surface into the render surface so the thumbnail
// becomes visible immediately (matching the normal enter/exit path).
self.apply_drawing_to_render_canvas(Some(element), SurfaceId::Current);
return true;
}
// No cached thumbnail yet: request one and continue with normal rendering.
// We'll capture it when the container finishes a full render (visited_children exit).
let _ = tree; // reserved for phase 2 selection/invalidation
self.requested_thumbnails.insert(element.id);
false
}
fn maybe_capture_thumbnail(&mut self, element: &Shape, tree: ShapesPoolRef, scale: f32) {
if !self.requested_thumbnails.remove(&element.id) {
return;
}
if !Self::thumb_is_eligible(element) {
return;
}
// Capture the container's extrect in device pixels from the Current surface.
let doc_rect = element.extrect(tree, scale);
let irect = self.doc_rect_to_surface_irect(doc_rect, scale);
let surface = self.surfaces.get_mut(SurfaceId::Current);
if let Some(image) = surface.image_snapshot_with_bounds(irect) {
self.thumbnails.insert(
element.id,
CachedThumbnail {
image,
doc_rect,
capture_scale: scale,
},
);
}
}
/// Combines every visible layer blur currently active (ancestors + shape)
/// into a single equivalent blur. Layer blur radii compound by adding their
/// variances (σ² = radius²), so we:
@ -2512,6 +2670,12 @@ impl RenderState {
if !node_render_state.flattened {
self.render_shape_exit(element, visited_mask, clip_bounds, target_surface)?;
}
// If we requested a thumbnail for this container, capture it now that the
// subtree has been rendered and composited into the Current surface.
if !export && target_surface == SurfaceId::Current {
let scale = self.get_scale();
self.maybe_capture_thumbnail(element, tree, scale);
}
continue;
}
@ -2555,6 +2719,15 @@ impl RenderState {
}
}
// LOD/fallback: if we have (or can use) a thumbnail for an eligible
// top-level container, draw it and skip the full subtree render.
if !export && target_surface == SurfaceId::Current {
let scale = self.get_scale();
if self.maybe_draw_thumbnail(element, tree, scale) {
continue;
}
}
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
// Skip render_shape_enter/exit for flattened containers