mirror of
https://github.com/penpot/penpot.git
synced 2026-07-28 17:06:21 +00:00
⚡ Avoid tiles glitching and improve pan/zoom performance
This commit is contained in:
parent
9670140448
commit
070f3f8522
@ -366,9 +366,15 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
#[wasm_error]
|
||||
pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
performance::begin_measure!("set_modifiers_start");
|
||||
let render_state = get_render_state();
|
||||
render_state.options.set_fast_mode(true);
|
||||
render_state.options.set_interactive_transform(true);
|
||||
with_state!(state, {
|
||||
// Capture per-shape crops from the clean Backbuffer now, once per
|
||||
// gesture, instead of speculatively on every Full frame (a full
|
||||
// DocAtlas snapshot + per-shape copies was the dominant settle cost).
|
||||
state.rebuild_drag_crop_cache();
|
||||
let render_state = get_render_state();
|
||||
render_state.options.set_fast_mode(true);
|
||||
render_state.options.set_interactive_transform(true);
|
||||
});
|
||||
performance::end_measure!("set_modifiers_start");
|
||||
Ok(())
|
||||
}
|
||||
@ -384,6 +390,11 @@ pub extern "C" fn set_modifiers_end() -> Result<()> {
|
||||
let render_state = get_render_state();
|
||||
render_state.options.set_fast_mode(false);
|
||||
render_state.options.set_interactive_transform(false);
|
||||
// The crop cache is only read during interactive transforms. Drop it now:
|
||||
// entries can share the Backbuffer texture (full-bounds snapshots), and a
|
||||
// live shared snapshot forces a copy-on-write of the Backbuffer on every
|
||||
// later write — a per-frame tax on pan/zoom if the images outlive the drag.
|
||||
render_state.backbuffer_crop_cache.clear();
|
||||
performance::end_measure!("set_modifiers_end");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@ pub use images::*;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
const MIN_TILES_PER_FRAME: i32 = 8;
|
||||
|
||||
#[repr(u8)]
|
||||
pub enum FrameType {
|
||||
None = 0,
|
||||
@ -397,6 +399,12 @@ pub(crate) struct RenderState {
|
||||
/// (must composite to present the work). Reset when current_tile
|
||||
/// changes.
|
||||
pub current_tile_had_shapes: bool,
|
||||
/// Count of uncached tiles composited in the current rAF. Reset at the top
|
||||
/// of `render_shape_tree_partial`; checked against the adaptive per-frame
|
||||
/// budget (`max(MIN_TILES_PER_FRAME, visible_tile_count)`) to yield (flush +
|
||||
/// return Partial) so a single rAF doesn't hand the GPU one huge command
|
||||
/// buffer. See `MIN_TILES_PER_FRAME`.
|
||||
pub tiles_on_frame: i32,
|
||||
/// During interactive transforms we keep `Target` between rAFs. Seed the
|
||||
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
||||
/// repeatedly overwrite tiles that have already been updated.
|
||||
@ -404,6 +412,10 @@ pub(crate) struct RenderState {
|
||||
/// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during
|
||||
/// drag, entries for the moved top-level selection are ensured here
|
||||
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
|
||||
/// True while Backbuffer holds an untouched full-quality frame (set on
|
||||
/// Full presents outside fast mode, cleared when any render pass or
|
||||
/// preview writes Backbuffer again). Gates building the drag crop cache.
|
||||
pub backbuffer_is_clean_full_frame: bool,
|
||||
}
|
||||
|
||||
pub struct InteractiveDragCrop {
|
||||
@ -583,8 +595,10 @@ impl RenderState {
|
||||
export_context: None,
|
||||
cache_cleared_this_render: false,
|
||||
current_tile_had_shapes: false,
|
||||
tiles_on_frame: 0,
|
||||
interactive_target_seeded: false,
|
||||
backbuffer_crop_cache: HashMap::default(),
|
||||
backbuffer_is_clean_full_frame: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -847,10 +861,6 @@ impl RenderState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) {
|
||||
self.surfaces.flush(SurfaceId::Backbuffer);
|
||||
}
|
||||
|
||||
pub fn flush_and_submit(&mut self) {
|
||||
self.surfaces.flush_and_submit(SurfaceId::Target);
|
||||
}
|
||||
@ -913,6 +923,7 @@ impl RenderState {
|
||||
/// This is currently not being used, but it's set there for testing purposes on
|
||||
/// upcoming tasks
|
||||
pub fn render_loading_overlay(&mut self) {
|
||||
self.backbuffer_is_clean_full_frame = false;
|
||||
let canvas = self.surfaces.canvas(SurfaceId::Backbuffer);
|
||||
let skia::ISize { width, height } = canvas.base_layer_size();
|
||||
|
||||
@ -1422,7 +1433,6 @@ impl RenderState {
|
||||
let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale());
|
||||
let text_stroke_blur_outset =
|
||||
Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||
let stroke_kinds: Vec<StrokeKind> =
|
||||
shape.visible_strokes().rev().map(|s| s.kind).collect();
|
||||
let (mut stroke_paragraphs_list, stroke_opacities): (Vec<_>, Vec<_>) = shape
|
||||
@ -1439,16 +1449,13 @@ impl RenderState {
|
||||
.unzip();
|
||||
if skip_effects {
|
||||
// Fast path: render fills and strokes only (skip shadows/blur).
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
text::render_fill_cached(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraph_builders,
|
||||
text_content,
|
||||
Some(fills_surface_id),
|
||||
None,
|
||||
None,
|
||||
text_fill_inset,
|
||||
None,
|
||||
)?;
|
||||
|
||||
for (i, (stroke_paragraphs, layer_opacity)) in stroke_paragraphs_list
|
||||
@ -1496,23 +1503,33 @@ impl RenderState {
|
||||
|
||||
let inner_shadows = shape.inner_shadow_paints();
|
||||
let blur_filter = shape.image_filter(1.);
|
||||
let mut paragraphs_with_shadows =
|
||||
text_content.paragraph_builder_group_from_text(Some(true));
|
||||
let needs_shadow_builders = parent_shadows.is_some()
|
||||
|| !drop_shadows.is_empty()
|
||||
|| !inner_shadows.is_empty();
|
||||
let mut paragraphs_with_shadows = if needs_shadow_builders {
|
||||
text_content.paragraph_builder_group_from_text(Some(true))
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let (mut stroke_paragraphs_with_shadows_list, _shadow_opacities): (
|
||||
Vec<_>,
|
||||
Vec<_>,
|
||||
) = shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
})
|
||||
.unzip();
|
||||
) = if needs_shadow_builders {
|
||||
shape
|
||||
.visible_strokes()
|
||||
.rev()
|
||||
.map(|stroke| {
|
||||
text::stroke_paragraph_builder_group_from_text(
|
||||
text_content,
|
||||
stroke,
|
||||
&shape.selrect(),
|
||||
Some(true),
|
||||
)
|
||||
})
|
||||
.unzip()
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
if let Some(parent_shadows) = parent_shadows {
|
||||
if !shape.has_visible_strokes() {
|
||||
@ -1560,17 +1577,14 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Text fills
|
||||
text::render(
|
||||
Some(self),
|
||||
None,
|
||||
// 2. Text fills — reuse cached laid-out paragraphs (no per-tile reshape).
|
||||
text::render_fill_cached(
|
||||
self,
|
||||
&shape,
|
||||
&mut paragraph_builders,
|
||||
text_content,
|
||||
Some(fills_surface_id),
|
||||
None,
|
||||
blur_filter.as_ref(),
|
||||
text_fill_inset,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// 3. Stroke drop shadows
|
||||
@ -1790,7 +1804,7 @@ impl RenderState {
|
||||
self.surfaces.update_render_context(self.render_area, scale);
|
||||
}
|
||||
|
||||
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||
pub fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||
self.backbuffer_crop_cache.clear();
|
||||
|
||||
// Collect candidate shapes that are "recortable" and visible in the current viewport.
|
||||
@ -1992,6 +2006,9 @@ impl RenderState {
|
||||
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
|
||||
let _start = performance::begin_timed_log!("render_from_cache");
|
||||
performance::begin_measure!("render_from_cache");
|
||||
// Previews overwrite Backbuffer with scaled/atlas content; the drag
|
||||
// crop cache must not be built from it.
|
||||
self.backbuffer_is_clean_full_frame = false;
|
||||
let bg_color = self.background_color;
|
||||
|
||||
// During fast mode (pan/zoom), if a previous full-quality render still has pending tiles,
|
||||
@ -2159,6 +2176,9 @@ impl RenderState {
|
||||
sync_render: bool,
|
||||
) -> Result<FrameType> {
|
||||
self.clear(tree);
|
||||
// A new render pass will write Backbuffer; it is only "clean" again
|
||||
// once the Full arm of continue_render_loop completes.
|
||||
self.backbuffer_is_clean_full_frame = false;
|
||||
|
||||
let _start = performance::begin_timed_log!("start_render_loop");
|
||||
let scale = self.get_scale();
|
||||
@ -2298,30 +2318,32 @@ impl RenderState {
|
||||
performance::begin_measure!("continue_render_loop");
|
||||
let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
|
||||
|
||||
if !self.options.is_interactive_transform() {
|
||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
||||
&self.viewbox,
|
||||
&self.tile_viewbox,
|
||||
self.background_color,
|
||||
);
|
||||
}
|
||||
|
||||
match frame_type {
|
||||
FrameType::None => {
|
||||
panic!("FrameType::None");
|
||||
}
|
||||
FrameType::Partial => {
|
||||
// Partial frame: just flush GPU work. The display shows the last
|
||||
// fully submitted frame; no need to copy or draw UI overlays here.
|
||||
self.flush();
|
||||
// Partial frame: flush AND submit so the GPU actually executes this
|
||||
// chunk's draws now
|
||||
self.surfaces.flush_and_submit(SurfaceId::Backbuffer);
|
||||
}
|
||||
FrameType::Full => {
|
||||
// A full-quality frame is now complete. Rebuild the per-shape crop
|
||||
// cache from the clean Backbuffer (no UI overlay yet) so that
|
||||
// interactive drag backgrounds don't include the grid overlay.
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
if !self.options.is_interactive_transform() {
|
||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
||||
&self.viewbox,
|
||||
&self.tile_viewbox,
|
||||
self.background_color,
|
||||
);
|
||||
}
|
||||
// A full-quality frame is now complete and Backbuffer is clean
|
||||
// (present_frame draws UI overlays on Target only). Don't rebuild
|
||||
// the drag crop cache here — it takes a full DocAtlas snapshot
|
||||
// (arming a whole-atlas COW on the next tile blit) plus per-shape
|
||||
// Backbuffer copies, far too expensive to pay on every settle.
|
||||
// Instead record that Backbuffer is usable; set_modifiers_start
|
||||
// builds the cache from it when a drag actually begins.
|
||||
self.backbuffer_is_clean_full_frame =
|
||||
!self.options.is_fast_mode() && !self.options.is_interactive_transform();
|
||||
// present_frame: copy clean Backbuffer → Target, draw UI/debug
|
||||
// overlays on Target only, then flush. Backbuffer stays overlay-free.
|
||||
self.present_frame(tree);
|
||||
@ -3545,6 +3567,10 @@ impl RenderState {
|
||||
) -> Result<FrameType> {
|
||||
let mut should_stop = false;
|
||||
self.viewer_render_root = base_object.copied();
|
||||
self.tiles_on_frame = 0;
|
||||
// Never fewer than the currently-visible tile count, so a
|
||||
// normal viewport composites all its visible tiles in one rAF
|
||||
let tile_budget = MIN_TILES_PER_FRAME.max(self.tile_viewbox.visible_rect.len());
|
||||
let root_ids = {
|
||||
if let Some(shape_id) = base_object {
|
||||
vec![*shape_id]
|
||||
@ -3605,6 +3631,20 @@ impl RenderState {
|
||||
tile_rect,
|
||||
);
|
||||
}
|
||||
|
||||
// Cap how many such tiles we submit per rAF so the GPU doesn't get one
|
||||
// giant command buffer. Skipped during interactive
|
||||
// transforms
|
||||
if allow_stop
|
||||
&& !self.options.is_interactive_transform()
|
||||
&& !self.viewer_masked_pass()
|
||||
{
|
||||
self.tiles_on_frame += 1;
|
||||
if self.tiles_on_frame >= tile_budget {
|
||||
self.viewer_render_root = None;
|
||||
return Ok(FrameType::Partial);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if self.tiles.is_empty_at(current_tile) {
|
||||
self.surfaces.remove_cached_tile_surface(current_tile);
|
||||
@ -3745,6 +3785,17 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_tiles_for_shape_unclamped(
|
||||
&mut self,
|
||||
shape: &Shape,
|
||||
tree: ShapesPoolRef,
|
||||
) -> TileRect {
|
||||
let scale = self.get_scale();
|
||||
let extrect = self.get_cached_extrect(shape, tree, scale);
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
tiles::get_tiles_for_rect(extrect, tile_size)
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a shape, check the indexes and update it's location in the tile set
|
||||
* returns the tiles that have changed in the process.
|
||||
@ -3869,6 +3920,10 @@ impl RenderState {
|
||||
self.surfaces.remove_cached_tile_surface(tile);
|
||||
}
|
||||
|
||||
pub fn invalidate_cached_tile_content(&mut self, tile: tiles::Tile) {
|
||||
self.surfaces.invalidate_cached_tile_surface(tile);
|
||||
}
|
||||
|
||||
/// Rebuild the tile index (shape→tile mapping) for all top-level shapes.
|
||||
/// This does NOT invalidate the tile texture cache — cached tile images
|
||||
/// survive so that fast-mode renders during pan still show shadows/blur.
|
||||
@ -3963,13 +4018,14 @@ impl RenderState {
|
||||
if let Some(shape) = tree.get(shape_id) {
|
||||
if shape_id != &Uuid::nil() {
|
||||
all_tiles.extend(self.update_shape_tiles(shape, tree));
|
||||
let unclamped = self.get_tiles_for_shape_unclamped(shape, tree);
|
||||
all_tiles.extend(self.surfaces.cached_tiles_in_rect(&unclamped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the changed tiles
|
||||
for tile in all_tiles {
|
||||
self.remove_cached_tile(tile);
|
||||
self.invalidate_cached_tile_content(tile);
|
||||
}
|
||||
|
||||
performance::end_measure!("rebuild_touched_tiles");
|
||||
|
||||
@ -11,7 +11,6 @@ use crate::math::Point;
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
const TEXTURES_CACHE_CAPACITY: usize = 1024;
|
||||
const TEXTURES_BATCH_DELETE: usize = 256;
|
||||
|
||||
// This is the amount of extra space we're going to give to all the surfaces to render shapes.
|
||||
@ -166,12 +165,48 @@ impl DocAtlas {
|
||||
new_bottom = new_bottom.max(doc_rect.bottom.ceil());
|
||||
}
|
||||
|
||||
// Add padding to reduce realloc frequency.
|
||||
// Pad to reduce realloc frequency. A realloc copies the whole atlas
|
||||
// (up to max_texture_size², hundreds of MB), so sides that actually
|
||||
// grow get a geometric margin — half the current atlas span, at least
|
||||
// a few tiles — making sustained pans cost O(log) reallocs instead of
|
||||
// one per tile.
|
||||
let pad = tiles::TILE_SIZE;
|
||||
new_left -= pad;
|
||||
new_top -= pad;
|
||||
new_right += pad;
|
||||
new_bottom += pad;
|
||||
if needs_init {
|
||||
new_left -= pad;
|
||||
new_top -= pad;
|
||||
new_right += pad;
|
||||
new_bottom += pad;
|
||||
} else {
|
||||
let grow_x = ((current_right - current_left) * 0.5).max(4.0 * tiles::TILE_SIZE);
|
||||
let grow_y = ((current_bottom - current_top) * 0.5).max(4.0 * tiles::TILE_SIZE);
|
||||
if new_left < current_left {
|
||||
new_left -= grow_x;
|
||||
}
|
||||
if new_right > current_right {
|
||||
new_right += grow_x;
|
||||
}
|
||||
if new_top < current_top {
|
||||
new_top -= grow_y;
|
||||
}
|
||||
if new_bottom > current_bottom {
|
||||
new_bottom += grow_y;
|
||||
}
|
||||
// Writes are clamped to doc_bounds, so texture past it is wasted;
|
||||
// clip the margin to the document (but never below the rect that
|
||||
// triggered this grow).
|
||||
if let Some(bounds) = self.doc_bounds {
|
||||
new_left = new_left.max((bounds.left - pad).min(doc_rect.left.floor()));
|
||||
new_top = new_top.max((bounds.top - pad).min(doc_rect.top.floor()));
|
||||
new_right = new_right.min((bounds.right + pad).max(doc_rect.right.ceil()));
|
||||
new_bottom = new_bottom.min((bounds.bottom + pad).max(doc_rect.bottom.ceil()));
|
||||
}
|
||||
// Never shrink below current coverage: the old-atlas copy must fit,
|
||||
// and shrinking would invite grow/shrink oscillation.
|
||||
new_left = new_left.min(current_left);
|
||||
new_top = new_top.min(current_top);
|
||||
new_right = new_right.max(current_right);
|
||||
new_bottom = new_bottom.max(current_bottom);
|
||||
}
|
||||
|
||||
let doc_w = (new_right - new_left).max(1.0);
|
||||
let doc_h = (new_bottom - new_top).max(1.0);
|
||||
@ -525,19 +560,32 @@ impl Surfaces {
|
||||
background: skia::Color,
|
||||
) {
|
||||
self.tiles.update(viewbox, tile_viewbox);
|
||||
let atlas_image = self.tile_atlas.image_snapshot();
|
||||
let canvas = self.backbuffer.canvas();
|
||||
canvas.clear(background);
|
||||
canvas.draw_atlas(
|
||||
&atlas_image,
|
||||
&self.tiles.transforms,
|
||||
&self.tiles.textures,
|
||||
None,
|
||||
skia::BlendMode::SrcOver,
|
||||
self.atlas_sampling_options,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
// Draw each tile via Surface::draw (clip + offset) instead of
|
||||
// draw_atlas over a full image_snapshot(): the snapshot shares the
|
||||
// atlas texture, so the first tile write after every present forced
|
||||
// a copy-on-write of the whole (max_texture_size²) atlas.
|
||||
for (transform, texture) in self.tiles.transforms.iter().zip(self.tiles.textures.iter()) {
|
||||
if texture.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dst = skia::Rect::from_xywh(
|
||||
transform.tx,
|
||||
transform.ty,
|
||||
texture.width(),
|
||||
texture.height(),
|
||||
);
|
||||
canvas.save();
|
||||
canvas.clip_rect(dst, None, false);
|
||||
self.tile_atlas.draw(
|
||||
canvas,
|
||||
(transform.tx - texture.left, transform.ty - texture.top),
|
||||
self.atlas_sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the persistent atlas onto the backbuffer using the current viewbox transform.
|
||||
@ -681,12 +729,6 @@ impl Surfaces {
|
||||
self.dirty_surfaces = 0;
|
||||
}
|
||||
|
||||
pub fn flush(&mut self, id: SurfaceId) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let surface = self.get_mut(id);
|
||||
gpu_state.context.flush_surface(surface);
|
||||
}
|
||||
|
||||
pub fn flush_and_submit(&mut self, id: SurfaceId) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let surface = self.get_mut(id);
|
||||
@ -1186,6 +1228,10 @@ impl Surfaces {
|
||||
self.tiles.has(tile)
|
||||
}
|
||||
|
||||
pub fn cached_tiles_in_rect(&self, rect: &TileRect) -> Vec<Tile> {
|
||||
self.tiles.cached_tiles_in_rect(rect)
|
||||
}
|
||||
|
||||
/// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into
|
||||
/// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image.
|
||||
///
|
||||
@ -1295,15 +1341,16 @@ impl Surfaces {
|
||||
|
||||
pub fn remove_cached_tile_surface(&mut self, tile: Tile) {
|
||||
let gpu_state = get_gpu_state();
|
||||
// Mark tile as invalid
|
||||
// Old content stays visible until new tile overwrites it atomically,
|
||||
// preventing flickering during tile re-renders.
|
||||
self.tiles.remove(tile);
|
||||
// Also clear the corresponding region in the persistent atlas to avoid
|
||||
// leaving stale pixels when shapes move/delete.
|
||||
let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile);
|
||||
}
|
||||
|
||||
pub fn invalidate_cached_tile_surface(&mut self, tile: Tile) {
|
||||
self.tiles.mark_stale(tile);
|
||||
}
|
||||
|
||||
pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option<skia::Image> {
|
||||
let Some(tile_ref) = self.tiles.get(tile) else {
|
||||
panic!("Tile not found {}:{}", tile.0, tile.1);
|
||||
@ -1319,11 +1366,30 @@ impl Surfaces {
|
||||
}
|
||||
|
||||
pub fn draw_cached_tile_into_backbuffer(&mut self, tile: Tile, rect: &Rect) {
|
||||
if let Some(image) = self.get_tile_image_from_tile_atlas(tile) {
|
||||
// let rect = tile.get_rect_with_offset(&offset);
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
backbuffer_canvas.draw_image_rect(&image, None, rect, &skia::Paint::default());
|
||||
// Draw the atlas region via Surface::draw (clip + transform) instead of
|
||||
// image_snapshot_with_bounds + draw_image_rect: a subset snapshot is an
|
||||
// eager GPU copy, paid per tile per frame on pan previews.
|
||||
let Some(tile_ref) = self.tiles.get(tile) else {
|
||||
panic!("Tile not found {}:{}", tile.0, tile.1);
|
||||
};
|
||||
let src = tile_ref.rect;
|
||||
if src.width() <= 0.0 || src.height() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let sampling_options = self.atlas_sampling_options;
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
backbuffer_canvas.save();
|
||||
backbuffer_canvas.clip_rect(*rect, None, false);
|
||||
backbuffer_canvas.translate((rect.left, rect.top));
|
||||
backbuffer_canvas.scale((rect.width() / src.width(), rect.height() / src.height()));
|
||||
backbuffer_canvas.translate((-src.left, -src.top));
|
||||
self.tile_atlas.draw(
|
||||
backbuffer_canvas,
|
||||
(0.0, 0.0),
|
||||
sampling_options,
|
||||
Some(&skia::Paint::default()),
|
||||
);
|
||||
backbuffer_canvas.restore();
|
||||
}
|
||||
|
||||
/// Draws the current tile directly to the backbuffer and cache surfaces without
|
||||
@ -1526,6 +1592,7 @@ pub struct TileTextureCache {
|
||||
textures: Vec<skia::Rect>,
|
||||
grid: HashMap<Tile, TileAtlasTextureRef>,
|
||||
removed: HashSet<Tile>,
|
||||
stale: HashSet<Tile>,
|
||||
}
|
||||
|
||||
impl TileTextureCache {
|
||||
@ -1537,6 +1604,7 @@ impl TileTextureCache {
|
||||
textures: Vec::with_capacity(capacity),
|
||||
grid: HashMap::with_capacity(capacity),
|
||||
removed: HashSet::with_capacity(capacity),
|
||||
stale: HashSet::with_capacity(capacity),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1546,7 +1614,9 @@ impl TileTextureCache {
|
||||
if let Some(tile_ref) = self.grid.remove(tile) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
self.stale.remove(tile);
|
||||
}
|
||||
self.removed.clear();
|
||||
}
|
||||
|
||||
fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) {
|
||||
@ -1554,7 +1624,7 @@ impl TileTextureCache {
|
||||
.grid
|
||||
.iter_mut()
|
||||
.filter_map(|(tile, _)| {
|
||||
if !tile_viewbox.is_visible(tile) {
|
||||
if !tile_viewbox.is_in_interest_area(tile) {
|
||||
Some(*tile)
|
||||
} else {
|
||||
None
|
||||
@ -1567,6 +1637,7 @@ impl TileTextureCache {
|
||||
if let Some(tile_ref) = self.grid.remove(tile) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
self.stale.remove(tile);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1595,6 +1666,10 @@ impl TileTextureCache {
|
||||
for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() {
|
||||
let tile = Tile(x, y);
|
||||
|
||||
if self.removed.contains(&tile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(tile_ref) = self.grid.get(&tile) else {
|
||||
continue;
|
||||
};
|
||||
@ -1615,19 +1690,30 @@ impl TileTextureCache {
|
||||
}
|
||||
|
||||
pub fn has(&self, tile: Tile) -> bool {
|
||||
self.grid.contains_key(&tile) && !self.removed.contains(&tile)
|
||||
self.grid.contains_key(&tile)
|
||||
&& !self.removed.contains(&tile)
|
||||
&& !self.stale.contains(&tile)
|
||||
}
|
||||
|
||||
pub fn cached_tiles_in_rect(&self, rect: &TileRect) -> Vec<Tile> {
|
||||
self.grid
|
||||
.keys()
|
||||
.filter(|tile| !self.removed.contains(tile) && rect.contains(tile))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef {
|
||||
if self.grid.len() > TEXTURES_CACHE_CAPACITY {
|
||||
let capacity = self.provider.length;
|
||||
if self.grid.len() >= capacity {
|
||||
// First we try to remove the obsolete tiles.
|
||||
self.gc();
|
||||
}
|
||||
|
||||
// If we still have a texture capacity problem, then
|
||||
// we try to remove all of those tiles that aren't
|
||||
// visible.
|
||||
if self.grid.len() > TEXTURES_CACHE_CAPACITY {
|
||||
// in the interest area.
|
||||
if self.grid.len() >= capacity {
|
||||
self.gc_non_visible(tile_viewbox);
|
||||
}
|
||||
|
||||
@ -1635,13 +1721,14 @@ impl TileTextureCache {
|
||||
panic!("Tile texture allocation failed {}:{}", tile.0, tile.1);
|
||||
};
|
||||
|
||||
self.grid.insert(*tile, tile_ref.clone());
|
||||
|
||||
if self.removed.contains(tile) {
|
||||
self.removed.remove(tile);
|
||||
if let Some(old) = self.grid.insert(*tile, tile_ref.clone()) {
|
||||
self.provider.deallocate(old);
|
||||
}
|
||||
|
||||
tile_ref.clone()
|
||||
self.removed.remove(tile);
|
||||
self.stale.remove(tile);
|
||||
|
||||
tile_ref
|
||||
}
|
||||
|
||||
pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> {
|
||||
@ -1652,17 +1739,20 @@ impl TileTextureCache {
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, tile: Tile) {
|
||||
if let Some(tile_ref) = self.grid.get(&tile) {
|
||||
if tile_ref.index < self.textures.len() {
|
||||
self.textures[tile_ref.index].set_empty();
|
||||
}
|
||||
}
|
||||
self.removed.insert(tile);
|
||||
}
|
||||
|
||||
pub fn mark_stale(&mut self, tile: Tile) {
|
||||
if self.grid.contains_key(&tile) && !self.removed.contains(&tile) {
|
||||
self.stale.insert(tile);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
for k in self.grid.keys() {
|
||||
self.removed.insert(*k);
|
||||
}
|
||||
// `removed` supersedes `stale` everywhere; drop the now-moot marks.
|
||||
self.stale.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,6 +114,18 @@ impl State {
|
||||
get_render_state().continue_render_loop(None, &self.shapes, timestamp)
|
||||
}
|
||||
|
||||
/// Build the drag crop cache at drag start, when Backbuffer still holds a
|
||||
/// clean full-quality frame. Entries from a previous drag/viewport are
|
||||
/// always dropped; if Backbuffer is mid-settle or holds a preview the drag
|
||||
/// simply renders live (no cache).
|
||||
pub fn rebuild_drag_crop_cache(&mut self) {
|
||||
let render_state = get_render_state();
|
||||
render_state.backbuffer_crop_cache.clear();
|
||||
if render_state.backbuffer_is_clean_full_frame {
|
||||
render_state.rebuild_backbuffer_crop_cache(&self.shapes);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_focus_mode(&mut self) {
|
||||
get_render_state().clear_focus_mode();
|
||||
}
|
||||
|
||||
@ -204,9 +204,12 @@ impl TileViewbox {
|
||||
}
|
||||
|
||||
pub fn is_visible(&self, tile: &Tile) -> bool {
|
||||
// TO CHECK self.interest_rect.contains(tile)
|
||||
self.visible_rect.contains(tile)
|
||||
}
|
||||
|
||||
pub fn is_in_interest_area(&self, tile: &Tile) -> bool {
|
||||
self.interest_rect.contains(tile)
|
||||
}
|
||||
}
|
||||
|
||||
pub const TILE_SIZE: f32 = 512.;
|
||||
@ -357,47 +360,20 @@ impl TileSpiral {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate tiles in spiral order from center (same algorithm as before).
|
||||
let mut cx = 0;
|
||||
let mut cy = 0;
|
||||
let cx = (columns / 2) as i32;
|
||||
let cy = (rows / 2) as i32;
|
||||
|
||||
let ratio = (columns as f32 / rows as f32).ceil() as i32;
|
||||
|
||||
let mut direction_current = 0;
|
||||
let mut direction_total_x = ratio;
|
||||
let mut direction_total_y = 1;
|
||||
let mut direction = 0;
|
||||
|
||||
self.offsets.push(Tile(cx, cy));
|
||||
while self.offsets.len() < total {
|
||||
match direction {
|
||||
0 => cx += 1,
|
||||
1 => cy += 1,
|
||||
2 => cx -= 1,
|
||||
3 => cy -= 1,
|
||||
_ => unreachable!("Invalid direction"),
|
||||
}
|
||||
|
||||
self.offsets.push(Tile(cx, cy));
|
||||
|
||||
direction_current += 1;
|
||||
let direction_total = if direction % 2 == 0 {
|
||||
direction_total_x
|
||||
} else {
|
||||
direction_total_y
|
||||
};
|
||||
|
||||
if direction_current == direction_total {
|
||||
if direction % 2 == 0 {
|
||||
direction_total_x += 1;
|
||||
} else {
|
||||
direction_total_y += 1;
|
||||
}
|
||||
direction = (direction + 1) % 4;
|
||||
direction_current = 0;
|
||||
for j in 0..rows as i32 {
|
||||
for i in 0..columns as i32 {
|
||||
self.offsets.push(Tile(i - cx, j - cy));
|
||||
}
|
||||
}
|
||||
|
||||
// Center-out priority: sort nearest-first by Manhattan distance, then
|
||||
// reverse so the consumer (`PendingTiles::update` pushes in iter order,
|
||||
// the render loop pops from the back) renders the nearest tiles first.
|
||||
self.offsets
|
||||
.sort_by_key(|t| t.0.unsigned_abs() + t.1.unsigned_abs());
|
||||
self.offsets.reverse();
|
||||
}
|
||||
}
|
||||
@ -462,12 +438,10 @@ impl PendingTiles {
|
||||
self.interest_cached.clear();
|
||||
self.interest_uncached.clear();
|
||||
|
||||
// Compute the scheduling center explicitly (inclusive range).
|
||||
// This avoids relying on `TileRect::center_x/center_y` semantics, which may be used
|
||||
// elsewhere with different expectations.
|
||||
// Scheduling center must match `TileSpiral::ensure`'s local center
|
||||
let center_tile = Tile(
|
||||
(spiral_rect.x1() + spiral_rect.x2()) / 2,
|
||||
(spiral_rect.y1() + spiral_rect.y2()) / 2,
|
||||
spiral_rect.x1() + (columns / 2),
|
||||
spiral_rect.y1() + (rows / 2),
|
||||
);
|
||||
for spiral_tile in self.spiral.iter() {
|
||||
let tile = Tile(spiral_tile.0 + center_tile.0, spiral_tile.1 + center_tile.1);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user