mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 13:29:07 +00:00
⚡ Paint visible and interest tiles once then crop to atlas
Size Current and layer surfaces to the viewport plus interest pad on window resize, walk each uncached batch once, and GPU-crop 512 tiles into the atlases so post-zoom HQ avoids re-walking the tree per tile. Soft-flush the GPU during paint-region batches so Partial submit does not stall on a full command backlog.
This commit is contained in:
parent
93bf6f5fce
commit
6139a954ea
@ -44,6 +44,7 @@ pub(crate) use resources::RenderResources;
|
||||
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
#[repr(u8)]
|
||||
pub enum FrameType {
|
||||
None = 0,
|
||||
@ -424,6 +425,14 @@ pub(crate) struct RenderState {
|
||||
/// Visible tiles were already presented this pass; interest-ring fill may
|
||||
/// still be running. Final Full should not re-present.
|
||||
pub viewport_presented: bool,
|
||||
/// Multi-tile paint-once into Current, then crop to atlas slots.
|
||||
paint_region: Option<PaintRegion>,
|
||||
}
|
||||
|
||||
/// Active paint-once region (visible viewport tiles or interest ring).
|
||||
struct PaintRegion {
|
||||
tiles: Vec<tiles::Tile>,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
pub struct InteractiveDragCrop {
|
||||
@ -563,6 +572,7 @@ impl RenderState {
|
||||
(width, height),
|
||||
sampling_options,
|
||||
tiles::get_tile_dimensions(),
|
||||
RenderOptions::default().dpr_viewport_interest_area_threshold,
|
||||
)?;
|
||||
|
||||
Self::assemble(width, height, surfaces)
|
||||
@ -609,6 +619,7 @@ impl RenderState {
|
||||
tile_atlas_flushed: false,
|
||||
drop_shadows_ops_warmed: false,
|
||||
viewport_presented: false,
|
||||
paint_region: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -910,6 +921,13 @@ impl RenderState {
|
||||
// affect pending_tiles generation.
|
||||
self.tile_viewbox
|
||||
.set_interest(self.options.dpr_viewport_interest_area_threshold);
|
||||
let dpr_width = (self.viewbox.width() * self.options.dpr).floor() as i32;
|
||||
let dpr_height = (self.viewbox.height() * self.options.dpr).floor() as i32;
|
||||
let _ = self.surfaces.resize_paint_surfaces(
|
||||
dpr_width,
|
||||
dpr_height,
|
||||
self.options.dpr_viewport_interest_area_threshold,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -936,7 +954,11 @@ impl RenderState {
|
||||
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
|
||||
let dpr_width = (width as f32 * self.options.dpr).floor() as i32;
|
||||
let dpr_height = (height as f32 * self.options.dpr).floor() as i32;
|
||||
self.surfaces.resize(dpr_width, dpr_height)?;
|
||||
self.surfaces.resize(
|
||||
dpr_width,
|
||||
dpr_height,
|
||||
self.options.dpr_viewport_interest_area_threshold,
|
||||
)?;
|
||||
self.viewbox.set_wh(width as f32, height as f32);
|
||||
self.tile_viewbox.update(&self.viewbox);
|
||||
|
||||
@ -1179,6 +1201,7 @@ impl RenderState {
|
||||
// Clear dirty flags for surfaces we just cleared
|
||||
self.surfaces.clear_dirty(dirty_surfaces_to_clear);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub fn clear_focus_mode(&mut self) {
|
||||
@ -2248,6 +2271,7 @@ impl RenderState {
|
||||
// reorder by distance to the center.
|
||||
self.current_tile = None;
|
||||
self.viewport_presented = false;
|
||||
self.paint_region = None;
|
||||
}
|
||||
|
||||
pub fn start_render_loop(
|
||||
@ -2258,6 +2282,7 @@ impl RenderState {
|
||||
sync_render: bool,
|
||||
) -> Result<FrameType> {
|
||||
self.clear(tree);
|
||||
let timestamp = self.render_budget_start(timestamp);
|
||||
|
||||
let _start = performance::begin_timed_log!("start_render_loop");
|
||||
let scale = self.get_scale();
|
||||
@ -2271,6 +2296,7 @@ impl RenderState {
|
||||
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom)
|
||||
// to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
|
||||
// growth far beyond real content.
|
||||
let t_bounds = performance::get_time();
|
||||
let doc_bounds = self.compute_document_bounds(base_object, tree);
|
||||
self.surfaces.atlas.set_doc_bounds(doc_bounds);
|
||||
|
||||
@ -2279,6 +2305,7 @@ impl RenderState {
|
||||
self.preserve_target_during_render = false;
|
||||
|
||||
if preserve_target && self.options.is_fast_mode() {
|
||||
let t_idx = performance::get_time();
|
||||
self.rebuild_tile_index(tree);
|
||||
}
|
||||
|
||||
@ -2334,6 +2361,7 @@ impl RenderState {
|
||||
let _tile_start = performance::begin_timed_log!("tile_cache_update");
|
||||
|
||||
performance::begin_measure!("tile_cache");
|
||||
let t_pending = performance::get_time();
|
||||
let only_visible = self.options.is_interactive_transform();
|
||||
self.pending_tiles
|
||||
.update(&self.tile_viewbox, &self.surfaces, only_visible);
|
||||
@ -2352,6 +2380,7 @@ impl RenderState {
|
||||
// stable viewbox (e.g. recoloring) which renders in one frame.
|
||||
let allow_stop =
|
||||
!preserve_target || self.zoom_changed() || self.options.is_interactive_transform();
|
||||
let t_cont = performance::get_time();
|
||||
frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
|
||||
|
||||
// This is an option to debug frames.
|
||||
@ -2416,9 +2445,14 @@ impl RenderState {
|
||||
) -> Result<FrameType> {
|
||||
performance::begin_measure!("continue_render_loop");
|
||||
let timestamp = self.render_budget_start(timestamp);
|
||||
let t0 = performance::get_time();
|
||||
let pending_before = self.pending_tiles.list.len();
|
||||
|
||||
let frame_type =
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?;
|
||||
|
||||
let t_tree = performance::get_time();
|
||||
|
||||
// `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not
|
||||
// presented (only flushed), so defer composition until the viewport is
|
||||
// ready and avoid re-snapshotting up to 4096² on every rAF during async
|
||||
@ -2428,6 +2462,7 @@ impl RenderState {
|
||||
&& !self.viewport_presented;
|
||||
|
||||
if should_compose {
|
||||
let t_c = performance::get_time();
|
||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
||||
&self.viewbox,
|
||||
&self.tile_viewbox,
|
||||
@ -2443,12 +2478,15 @@ impl RenderState {
|
||||
// Drain tile GPU work (Current / tile atlas / cache) without
|
||||
// presenting Target and without re-snapshotting the tile atlas.
|
||||
// Composition stays deferred until ViewportReady/Full.
|
||||
let t_f = performance::get_time();
|
||||
crate::get_gpu_state().context.flush_and_submit();
|
||||
}
|
||||
FrameType::ViewportReady => {
|
||||
// Visible tiles are done: present now so the user sees the
|
||||
// viewport without waiting for interest-ring pre-render.
|
||||
let t_p = performance::get_time();
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
let t_crop = performance::get_time();
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
self.present_frame(tree);
|
||||
@ -2460,6 +2498,7 @@ impl RenderState {
|
||||
if !self.viewport_presented {
|
||||
// A full-quality frame is now complete (no early viewport
|
||||
// present). Rebuild crop cache and present.
|
||||
let t_p = performance::get_time();
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
@ -2481,7 +2520,6 @@ impl RenderState {
|
||||
tree: ShapesPoolRef,
|
||||
timestamp: i32,
|
||||
) -> Result<FrameType> {
|
||||
let timestamp = self.render_budget_start(timestamp);
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
|
||||
|
||||
// Same composition as `continue_render_loop` for full frames: snapshot only the
|
||||
@ -2616,24 +2654,6 @@ impl RenderState {
|
||||
Ok((data.as_bytes().to_vec(), width, height))
|
||||
}
|
||||
|
||||
/// Anchor the progressive render budget to wall-clock now when the
|
||||
/// caller-provided timestamp is unusable:
|
||||
/// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end).
|
||||
/// - rAF may hand a timestamp that is already older than the budget when
|
||||
/// the handler runs late. Using that stamp made `should_stop_rendering`
|
||||
/// yield after a few nodes with ~0ms of real work.
|
||||
#[inline]
|
||||
fn render_budget_start(&self, timestamp: i32) -> i32 {
|
||||
let now = performance::get_time();
|
||||
if timestamp <= 0 {
|
||||
return now;
|
||||
}
|
||||
if now - timestamp > self.options.max_blocking_time_ms {
|
||||
return now;
|
||||
}
|
||||
timestamp
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool {
|
||||
if iteration % self.options.node_batch_threshold != 0 {
|
||||
@ -2658,6 +2678,31 @@ impl RenderState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Push pending GPU work without waiting so Partial `flush_and_submit`
|
||||
/// does not absorb an entire paint-region's worth of ops in one spike.
|
||||
#[inline]
|
||||
fn soft_flush_gpu(&mut self) {
|
||||
crate::get_gpu_state().context.flush(None);
|
||||
}
|
||||
|
||||
/// Normalize the render time budget start.
|
||||
///
|
||||
/// - Frontend sometimes passes `0` (finalize-view / debounced zoom-end).
|
||||
/// - rAF may hand a timestamp that is already older than the budget when
|
||||
/// the handler runs late. Using that stamp made `should_stop_rendering`
|
||||
/// yield after a few nodes with ~0ms of real work.
|
||||
#[inline]
|
||||
fn render_budget_start(&self, timestamp: i32) -> i32 {
|
||||
let now = performance::get_time();
|
||||
if timestamp <= 0 {
|
||||
return now;
|
||||
}
|
||||
if now - timestamp > self.options.max_blocking_time_ms {
|
||||
return now;
|
||||
}
|
||||
timestamp
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clip_target_surface_to_stack(
|
||||
&mut self,
|
||||
@ -3193,6 +3238,7 @@ impl RenderState {
|
||||
|
||||
/// Renders element drop shadows to DropShadows surface and composites to Current.
|
||||
/// Used for both normal shadow rendering and pre-layer rendering (frame_clip_layer_blur).
|
||||
/// Returns `true` when at least one visible drop shadow was composited.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_element_drop_shadows_and_composite(
|
||||
&mut self,
|
||||
@ -3618,15 +3664,9 @@ impl RenderState {
|
||||
// Touch DropShadows→Current once per tile when no shape has
|
||||
// composited real shadows yet. Omitting this entirely made
|
||||
// flush_and_submit very slow (ops-task ordering); repeating
|
||||
// it per shape was waste.
|
||||
self.surfaces.draw_into(
|
||||
SurfaceId::DropShadows,
|
||||
target_surface,
|
||||
Some(&skia::Paint::default()),
|
||||
);
|
||||
// it per shape was wasted GPU work.
|
||||
self.surfaces
|
||||
.canvas(SurfaceId::DropShadows)
|
||||
.clear(skia::Color::TRANSPARENT);
|
||||
.draw_into(SurfaceId::DropShadows, target_surface, None);
|
||||
self.drop_shadows_ops_warmed = true;
|
||||
}
|
||||
|
||||
@ -3646,6 +3686,7 @@ impl RenderState {
|
||||
Cow::Borrowed(element)
|
||||
};
|
||||
|
||||
let is_text = matches!(element_for_inline.shape_type, Type::Text(_));
|
||||
self.render_shape(
|
||||
&element_for_inline,
|
||||
clip_bounds.clone(),
|
||||
@ -3727,6 +3768,13 @@ impl RenderState {
|
||||
}
|
||||
|
||||
// We try to avoid doing too many calls to get_time
|
||||
if allow_stop && iteration % self.options.node_batch_threshold == 0 {
|
||||
// Kick GPU work while the walker continues so Partial
|
||||
// flush_and_submit does not wait on a full paint-region backlog.
|
||||
if self.paint_region.is_some() {
|
||||
self.soft_flush_gpu();
|
||||
}
|
||||
}
|
||||
if allow_stop && self.should_stop_rendering(iteration, timestamp) {
|
||||
return Ok((is_empty, true));
|
||||
}
|
||||
@ -3736,6 +3784,158 @@ impl RenderState {
|
||||
Ok((is_empty, false))
|
||||
}
|
||||
|
||||
fn update_render_context_for_area(&mut self, area: Rect) {
|
||||
let scale = self.get_scale();
|
||||
self.render_area = area;
|
||||
let margins = self.surfaces.margins();
|
||||
let margin_w = margins.width as f32 / scale;
|
||||
let margin_h = margins.height as f32 / scale;
|
||||
self.render_area_with_margins = skia::Rect::from_ltrb(
|
||||
self.render_area.left - margin_w,
|
||||
self.render_area.top - margin_h,
|
||||
self.render_area.right + margin_w,
|
||||
self.render_area.bottom + margin_h,
|
||||
);
|
||||
self.surfaces.update_render_context(self.render_area, scale);
|
||||
}
|
||||
|
||||
/// Drain pending uncached tiles into a paint-once region when safe.
|
||||
/// Returns true when `paint_region` was started and nodes were seeded.
|
||||
fn try_begin_paint_region(
|
||||
&mut self,
|
||||
root_ids: &[Uuid],
|
||||
tree: ShapesPoolRef,
|
||||
) -> Result<bool> {
|
||||
if self.viewer_masked_pass() || self.options.is_interactive_transform() {
|
||||
return Ok(false);
|
||||
}
|
||||
if self.paint_region.is_some() || !self.pending_nodes.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let label = if self.pending_tiles.has_deferred_interest() {
|
||||
"visible"
|
||||
} else {
|
||||
"interest"
|
||||
};
|
||||
|
||||
let mut region_tiles = Vec::new();
|
||||
let mut remaining = Vec::new();
|
||||
for tile in self.pending_tiles.list.drain(..) {
|
||||
if self.surfaces.has_cached_tile_surface(tile) {
|
||||
continue;
|
||||
}
|
||||
if self.tiles.is_empty_at(tile) {
|
||||
remaining.push(tile);
|
||||
continue;
|
||||
}
|
||||
region_tiles.push(tile);
|
||||
}
|
||||
self.pending_tiles.list = remaining;
|
||||
|
||||
if region_tiles.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let scale = self.get_scale();
|
||||
let mut area = Rect::new_empty();
|
||||
for tile in ®ion_tiles {
|
||||
let r = tiles::get_tile_rect(*tile, scale);
|
||||
if area.is_empty() {
|
||||
area = r;
|
||||
} else {
|
||||
area.join(r);
|
||||
}
|
||||
}
|
||||
|
||||
if region_tiles.len() == 1 || !self.surfaces.region_fits_paint_surface(area, scale) {
|
||||
// Restore for the single-tile path (pop from end).
|
||||
self.pending_tiles.list.extend(region_tiles);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.update_render_context_for_area(area);
|
||||
self.current_tile = Some(region_tiles[0]);
|
||||
self.current_tile_had_shapes = true;
|
||||
self.tile_atlas_flushed = false;
|
||||
self.drop_shadows_ops_warmed = false;
|
||||
|
||||
let mut shape_ids: HashSet<Uuid> = HashSet::default();
|
||||
let mut region_has_bg_blur = false;
|
||||
for tile in ®ion_tiles {
|
||||
if let Some(ids) = self.tiles.get_shapes_at(*tile) {
|
||||
for id in ids {
|
||||
shape_ids.insert(*id);
|
||||
if !region_has_bg_blur {
|
||||
region_has_bg_blur = tree
|
||||
.get(id)
|
||||
.is_some_and(|s| s.visible_background_blur().is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut valid_ids = Vec::new();
|
||||
if region_has_bg_blur {
|
||||
valid_ids.extend(root_ids.iter().copied());
|
||||
} else {
|
||||
for root_id in root_ids {
|
||||
if shape_ids.contains(root_id) {
|
||||
valid_ids.push(*root_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if valid_ids.is_empty() {
|
||||
self.pending_tiles.list.extend(region_tiles);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.pending_nodes
|
||||
.extend(valid_ids.into_iter().map(|id| NodeRenderState {
|
||||
id,
|
||||
visited_children: false,
|
||||
clip_bounds: None,
|
||||
visited_mask: false,
|
||||
mask: false,
|
||||
flattened: false,
|
||||
}));
|
||||
|
||||
self.paint_region = Some(PaintRegion {
|
||||
tiles: region_tiles,
|
||||
label,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn apply_paint_region_to_atlas(&mut self, region: &PaintRegion) -> Result<()> {
|
||||
if self.tile_atlas_flushed {
|
||||
crate::get_gpu_state().context.flush_and_submit();
|
||||
}
|
||||
self.cache_cleared_this_render = true;
|
||||
let scale = self.get_scale();
|
||||
let render_area = self.render_area;
|
||||
|
||||
for tile in ®ion.tiles {
|
||||
let tile_doc_rect = tiles::get_tile_rect(*tile, scale);
|
||||
let src = self.surfaces.tile_drawable_src_in_region(
|
||||
tile_doc_rect,
|
||||
render_area,
|
||||
scale,
|
||||
);
|
||||
let aligned = self.get_aligned_tile_bounds(*tile);
|
||||
self.surfaces.draw_current_src_into_tile_atlas(
|
||||
&self.tile_viewbox,
|
||||
tile,
|
||||
&aligned,
|
||||
true,
|
||||
tile_doc_rect,
|
||||
src,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn render_shape_tree_partial(
|
||||
&mut self,
|
||||
base_object: Option<&Uuid>,
|
||||
@ -3779,31 +3979,37 @@ impl RenderState {
|
||||
}
|
||||
performance::end_measure!("render_shape_tree::uncached");
|
||||
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
||||
// the tile has unfinished work from a previous PAF
|
||||
// (`current_tile_had_shapes` was set when we populated pending_nodes
|
||||
// for this tile).
|
||||
if !is_empty || self.current_tile_had_shapes {
|
||||
if self.options.is_interactive_transform() {
|
||||
// During drag, avoid snapshot-based caching. Draw Current directly
|
||||
// into Target (and Cache) to reduce stalls.
|
||||
self.surfaces.draw_current_tile_into_backbuffer(
|
||||
&tile_rect,
|
||||
self.background_color,
|
||||
surfaces::DrawOnCache::No,
|
||||
);
|
||||
} else {
|
||||
self.apply_render_to_final_canvas()?;
|
||||
if let Some(region) = self.paint_region.take() {
|
||||
if !is_empty || self.current_tile_had_shapes {
|
||||
self.apply_paint_region_to_atlas(®ion)?;
|
||||
}
|
||||
} else {
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
||||
// the tile has unfinished work from a previous PAF
|
||||
// (`current_tile_had_shapes` was set when we populated pending_nodes
|
||||
// for this tile).
|
||||
if !is_empty || self.current_tile_had_shapes {
|
||||
if self.options.is_interactive_transform() {
|
||||
// During drag, avoid snapshot-based caching. Draw Current directly
|
||||
// into Target (and Cache) to reduce stalls.
|
||||
self.surfaces.draw_current_tile_into_backbuffer(
|
||||
&tile_rect,
|
||||
self.background_color,
|
||||
surfaces::DrawOnCache::No,
|
||||
);
|
||||
} else {
|
||||
self.apply_render_to_final_canvas()?;
|
||||
}
|
||||
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render_workspace_current_tile(
|
||||
self,
|
||||
"".to_string(),
|
||||
current_tile,
|
||||
tile_rect,
|
||||
);
|
||||
if self.options.is_debug_visible() {
|
||||
debug::render_workspace_current_tile(
|
||||
self,
|
||||
"".to_string(),
|
||||
current_tile,
|
||||
tile_rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if self.tiles.is_empty_at(current_tile) {
|
||||
@ -3815,6 +4021,11 @@ impl RenderState {
|
||||
.canvas(SurfaceId::Current)
|
||||
.clear(self.background_color);
|
||||
|
||||
// Prefer paint-once for the remaining uncached batch (visible or interest).
|
||||
if self.try_begin_paint_region(&root_ids, tree)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we finish processing every node rendering is complete
|
||||
// let's check if there are more pending nodes
|
||||
if let Some(next_tile) = self.pending_tiles.pop() {
|
||||
@ -3829,44 +4040,47 @@ impl RenderState {
|
||||
|
||||
let viewer_masked_pass = self.viewer_masked_pass();
|
||||
|
||||
let Some(ids) = self.tiles.get_shapes_at(next_tile) else {
|
||||
// If the tile is empty we do not need to render it.
|
||||
continue;
|
||||
};
|
||||
let valid_ids = {
|
||||
let Some(ids) = self.tiles.get_shapes_at(next_tile) else {
|
||||
// If the tile is empty we do not need to render it.
|
||||
continue;
|
||||
};
|
||||
|
||||
// Never skip based on cached surfaces during viewer masked passes.
|
||||
if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) {
|
||||
// If the tile is cached, then we do not need to
|
||||
// render it.
|
||||
continue;
|
||||
}
|
||||
// Never skip based on cached surfaces during viewer masked passes.
|
||||
if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) {
|
||||
// If the tile is cached, then we do not need to
|
||||
// render it.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if any shape on this tile has a background blur.
|
||||
// If so, we need ALL root shapes rendered (not just those
|
||||
// assigned to this tile) because the blur snapshots Current
|
||||
// which must contain the shapes behind it.
|
||||
let tile_has_bg_blur = ids.iter().any(|id| {
|
||||
tree.get(id)
|
||||
.is_some_and(|s| s.visible_background_blur().is_some())
|
||||
});
|
||||
// Check if any shape on this tile has a background blur.
|
||||
// If so, we need ALL root shapes rendered (not just those
|
||||
// assigned to this tile) because the blur snapshots Current
|
||||
// which must contain the shapes behind it.
|
||||
let tile_has_bg_blur = ids.iter().any(|id| {
|
||||
tree.get(id)
|
||||
.is_some_and(|s| s.visible_background_blur().is_some())
|
||||
});
|
||||
|
||||
// We only need first level shapes, in the same order as the parent node.
|
||||
//
|
||||
// During interactive transforms we may invalidate only the modified shapes
|
||||
// (to avoid massive ancestor eviction). However, we still composite full
|
||||
// tiles (we clear the tile rect before drawing Current), so we must render
|
||||
// all root shapes that can contribute to this tile; otherwise, unchanged
|
||||
// siblings inside the same tile would disappear.
|
||||
let mut valid_ids = Vec::with_capacity(ids.len());
|
||||
if self.options.is_interactive_transform() || tile_has_bg_blur {
|
||||
valid_ids.extend(root_ids.iter().copied());
|
||||
} else {
|
||||
for root_id in root_ids.iter() {
|
||||
if ids.contains(root_id) {
|
||||
valid_ids.push(*root_id);
|
||||
// We only need first level shapes, in the same order as the parent node.
|
||||
//
|
||||
// During interactive transforms we may invalidate only the modified shapes
|
||||
// (to avoid massive ancestor eviction). However, we still composite full
|
||||
// tiles (we clear the tile rect before drawing Current), so we must render
|
||||
// all root shapes that can contribute to this tile; otherwise, unchanged
|
||||
// siblings inside the same tile would disappear.
|
||||
let mut valid_ids = Vec::with_capacity(ids.len());
|
||||
if self.options.is_interactive_transform() || tile_has_bg_blur {
|
||||
valid_ids.extend(root_ids.iter().copied());
|
||||
} else {
|
||||
for root_id in root_ids.iter() {
|
||||
if ids.contains(root_id) {
|
||||
valid_ids.push(*root_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
valid_ids
|
||||
};
|
||||
|
||||
if !valid_ids.is_empty() {
|
||||
self.current_tile_had_shapes = true;
|
||||
@ -4091,11 +4305,14 @@ impl RenderState {
|
||||
pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) {
|
||||
let zoom_changed = self.zoom_changed();
|
||||
performance::begin_measure!("rebuild_tile_index");
|
||||
let t0 = performance::get_time();
|
||||
let mut shapes_visited = 0u32;
|
||||
let mut nodes = Vec::<Uuid>::with_capacity(64);
|
||||
nodes.push(Uuid::nil());
|
||||
while let Some(shape_id) = nodes.pop() {
|
||||
if let Some(shape) = tree.get(&shape_id) {
|
||||
if shape_id != Uuid::nil() {
|
||||
shapes_visited += 1;
|
||||
if zoom_changed {
|
||||
let _ = self.update_shape_tiles(shape, tree);
|
||||
} else {
|
||||
|
||||
@ -14,11 +14,12 @@ 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.
|
||||
// If it's too big it could affect performance.
|
||||
// Per-tile blur/shadow spill into the paint surface. Also used as the
|
||||
// outer pad when Current covers a multi-tile region.
|
||||
const TILE_SIZE: i32 = tiles::TILE_SIZE as i32;
|
||||
const TILE_SIZE_MULTIPLIER: i32 = 2;
|
||||
const TILE_MARGIN_SIZE: i32 = TILE_SIZE * TILE_SIZE_MULTIPLIER / 4;
|
||||
/// Drawable 512² for the legacy single-tile path (content starts after margins).
|
||||
const TILE_DRAWABLE_RECT: IRect = IRect {
|
||||
left: TILE_MARGIN_SIZE,
|
||||
top: TILE_MARGIN_SIZE,
|
||||
@ -27,6 +28,21 @@ const TILE_DRAWABLE_RECT: IRect = IRect {
|
||||
};
|
||||
const DOC_ATLAS_MAX_DIM: i32 = 4096;
|
||||
|
||||
/// Pixel size for Current + layer surfaces: viewport plus interest ring and
|
||||
/// blur margins. Resized only with the window (not on zoom HQ passes).
|
||||
pub fn paint_surface_dims(
|
||||
viewport_w: i32,
|
||||
viewport_h: i32,
|
||||
interest_tiles: i32,
|
||||
max_texture_size: i32,
|
||||
) -> skia::ISize {
|
||||
let interest = interest_tiles.max(0);
|
||||
let pad = interest * TILE_SIZE + TILE_MARGIN_SIZE;
|
||||
let w = (viewport_w + 2 * pad).clamp(TILE_SIZE + 2 * TILE_MARGIN_SIZE, max_texture_size);
|
||||
let h = (viewport_h + 2 * pad).clamp(TILE_SIZE + 2 * TILE_MARGIN_SIZE, max_texture_size);
|
||||
skia::ISize::new(w, h)
|
||||
}
|
||||
|
||||
/// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without
|
||||
/// `image_snapshot` (avoids per-tile sync stalls on WebGL).
|
||||
fn draw_surface_src_rect_to_dst(
|
||||
@ -463,14 +479,16 @@ impl Surfaces {
|
||||
(width, height): (i32, i32),
|
||||
sampling_options: skia::SamplingOptions,
|
||||
tile_dims: skia::ISize,
|
||||
interest_tiles: i32,
|
||||
) -> Result<Self> {
|
||||
let gpu_state = get_gpu_state();
|
||||
let max_texture_size = gpu_state.max_texture_size();
|
||||
|
||||
let extra_tile_dims = skia::ISize::new(
|
||||
tile_dims.width * TILE_SIZE_MULTIPLIER,
|
||||
tile_dims.height * TILE_SIZE_MULTIPLIER,
|
||||
);
|
||||
let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4);
|
||||
// Current + layers cover viewport + interest + blur margins so a
|
||||
// paint-once region pass needs no surface recreate on zoom.
|
||||
let extra_tile_dims =
|
||||
paint_surface_dims(width, height, interest_tiles, max_texture_size);
|
||||
let margins = skia::ISize::new(TILE_MARGIN_SIZE, TILE_MARGIN_SIZE);
|
||||
|
||||
let target = gpu_state.create_target_surface(width, height)?;
|
||||
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?;
|
||||
@ -478,7 +496,6 @@ impl Surfaces {
|
||||
let backbuffer =
|
||||
gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?;
|
||||
|
||||
let max_texture_size = gpu_state.max_texture_size();
|
||||
let tile_atlas = gpu_state.create_surface_with_dimensions(
|
||||
"tile_atlas".to_string(),
|
||||
max_texture_size,
|
||||
@ -498,7 +515,7 @@ impl Surfaces {
|
||||
gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?;
|
||||
let shape_strokes =
|
||||
gpu_state.create_surface_with_isize("shape_strokes".to_string(), extra_tile_dims)?;
|
||||
let export = gpu_state.create_surface_with_isize("export".to_string(), extra_tile_dims)?;
|
||||
let export = gpu_state.create_surface_with_isize("export".to_string(), tile_dims)?;
|
||||
|
||||
let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?;
|
||||
let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?;
|
||||
@ -665,13 +682,67 @@ impl Surfaces {
|
||||
self.margins
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, new_width: i32, new_height: i32) -> Result<()> {
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
new_width: i32,
|
||||
new_height: i32,
|
||||
interest_tiles: i32,
|
||||
) -> Result<()> {
|
||||
let gpu_state = get_gpu_state();
|
||||
|
||||
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?;
|
||||
self.resize_paint_surfaces(new_width, new_height, interest_tiles)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recreate Current + layer surfaces for the viewport (+ interest pad).
|
||||
/// Called only from window resize / init — not from zoom HQ passes.
|
||||
pub fn resize_paint_surfaces(
|
||||
&mut self,
|
||||
viewport_w: i32,
|
||||
viewport_h: i32,
|
||||
interest_tiles: i32,
|
||||
) -> Result<()> {
|
||||
let max_texture_size = get_gpu_state().max_texture_size();
|
||||
let dims = paint_surface_dims(viewport_w, viewport_h, interest_tiles, max_texture_size);
|
||||
if dims == self.extra_tile_dims {
|
||||
return Ok(());
|
||||
}
|
||||
self.extra_tile_dims = dims;
|
||||
self.margins = skia::ISize::new(TILE_MARGIN_SIZE, TILE_MARGIN_SIZE);
|
||||
|
||||
let recreate = |name: &str, surface: &mut skia::Surface| -> Result<()> {
|
||||
*surface = surface
|
||||
.new_surface_with_dimensions(dims)
|
||||
.ok_or(Error::CriticalError(format!(
|
||||
"Failed to recreate {name} surface"
|
||||
)))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
recreate("current", &mut self.current)?;
|
||||
recreate("filter", &mut self.filter)?;
|
||||
recreate("drop_shadows", &mut self.drop_shadows)?;
|
||||
recreate("inner_shadows", &mut self.inner_shadows)?;
|
||||
recreate("text_drop_shadows", &mut self.text_drop_shadows)?;
|
||||
recreate("shape_fills", &mut self.shape_fills)?;
|
||||
recreate("shape_strokes", &mut self.shape_strokes)?;
|
||||
self.clear_all_dirty();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn paint_surface_size(&self) -> skia::ISize {
|
||||
self.extra_tile_dims
|
||||
}
|
||||
|
||||
pub fn tile_margin_size() -> i32 {
|
||||
TILE_MARGIN_SIZE
|
||||
}
|
||||
|
||||
pub fn single_tile_drawable_rect() -> IRect {
|
||||
TILE_DRAWABLE_RECT
|
||||
}
|
||||
|
||||
pub fn snapshot(&mut self, id: SurfaceId) -> skia::Image {
|
||||
let surface = self.get_mut(id);
|
||||
surface.image_snapshot()
|
||||
@ -1211,9 +1282,28 @@ impl Surfaces {
|
||||
tile_rect: &skia::Rect,
|
||||
skip_cache_surface: bool,
|
||||
tile_doc_rect: skia::Rect,
|
||||
) {
|
||||
self.draw_current_src_into_tile_atlas(
|
||||
tile_viewbox,
|
||||
tile,
|
||||
tile_rect,
|
||||
skip_cache_surface,
|
||||
tile_doc_rect,
|
||||
skia::Rect::from(TILE_DRAWABLE_RECT),
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload an arbitrary Current src rect (region paint-once crop) into the atlases.
|
||||
pub fn draw_current_src_into_tile_atlas(
|
||||
&mut self,
|
||||
tile_viewbox: &TileViewbox,
|
||||
tile: &Tile,
|
||||
tile_rect: &skia::Rect,
|
||||
skip_cache_surface: bool,
|
||||
tile_doc_rect: skia::Rect,
|
||||
src: skia::Rect,
|
||||
) {
|
||||
let gpu_state = get_gpu_state();
|
||||
let src = skia::Rect::from(TILE_DRAWABLE_RECT);
|
||||
let sampling = self.sampling_options;
|
||||
|
||||
// DocAtlas + tile atlas via Surface::draw (no image_snapshot sync).
|
||||
@ -1251,6 +1341,27 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a doc-space region (with blur margins) fits in Current at `scale`.
|
||||
pub fn region_fits_paint_surface(&self, render_area: skia::Rect, scale: f32) -> bool {
|
||||
let need_w =
|
||||
(render_area.width() * scale).ceil() as i32 + 2 * self.margins.width;
|
||||
let need_h =
|
||||
(render_area.height() * scale).ceil() as i32 + 2 * self.margins.height;
|
||||
need_w <= self.current.width() && need_h <= self.current.height()
|
||||
}
|
||||
|
||||
/// Pixel rect inside Current for a tile given the region `render_area` and scale.
|
||||
pub fn tile_drawable_src_in_region(
|
||||
&self,
|
||||
tile_doc_rect: skia::Rect,
|
||||
render_area: skia::Rect,
|
||||
scale: f32,
|
||||
) -> skia::Rect {
|
||||
let x = self.margins.width as f32 + (tile_doc_rect.left - render_area.left) * scale;
|
||||
let y = self.margins.height as f32 + (tile_doc_rect.top - render_area.top) * scale;
|
||||
skia::Rect::from_xywh(x, y, TILE_SIZE as f32, TILE_SIZE as f32)
|
||||
}
|
||||
|
||||
pub fn has_cached_tile_surface(&self, tile: Tile) -> bool {
|
||||
self.tiles.has(tile)
|
||||
}
|
||||
@ -1383,42 +1494,33 @@ impl Surfaces {
|
||||
draw_on_cache: DrawOnCache,
|
||||
) {
|
||||
let sampling_options = self.sampling_options;
|
||||
let src_rect = IRect::from_xywh(
|
||||
self.margins.width,
|
||||
self.margins.height,
|
||||
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width,
|
||||
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height,
|
||||
// Current is viewport-sized for paint-once regions. During interactive
|
||||
// drag we still paint one tile into the top-left padded slot; blit only
|
||||
// that slot or we wipe the rest of Target with cleared Current pixels.
|
||||
let pad = (TILE_SIZE + 2 * TILE_MARGIN_SIZE) as f32;
|
||||
let src = skia::Rect::from_xywh(0.0, 0.0, pad, pad);
|
||||
let dst = skia::Rect::from_xywh(
|
||||
tile_rect.left - TILE_MARGIN_SIZE as f32,
|
||||
tile_rect.top - TILE_MARGIN_SIZE as f32,
|
||||
pad,
|
||||
pad,
|
||||
);
|
||||
let src_rect_f = skia::Rect::from(src_rect);
|
||||
|
||||
let backbuffer_canvas = self.backbuffer.canvas();
|
||||
|
||||
// Draw background
|
||||
// let mut paint = skia::Paint::default();
|
||||
// paint.set_color(color);
|
||||
// backbuffer_canvas.draw_rect(tile_rect, &paint);
|
||||
|
||||
// Draw current surface directly to target (no snapshot)
|
||||
self.current.draw(
|
||||
backbuffer_canvas,
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
draw_surface_src_rect_to_dst(
|
||||
&mut self.current,
|
||||
self.backbuffer.canvas(),
|
||||
src,
|
||||
dst,
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
|
||||
// Also draw to cache for render_from_cache
|
||||
if draw_on_cache == DrawOnCache::Yes {
|
||||
self.current.draw(
|
||||
draw_surface_src_rect_to_dst(
|
||||
&mut self.current,
|
||||
self.cache.canvas(),
|
||||
(
|
||||
tile_rect.left - src_rect_f.left,
|
||||
tile_rect.top - src_rect_f.top,
|
||||
),
|
||||
src,
|
||||
dst,
|
||||
sampling_options,
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -423,6 +423,10 @@ impl PendingTiles {
|
||||
true
|
||||
}
|
||||
|
||||
pub fn has_deferred_interest(&self) -> bool {
|
||||
!self.deferred_interest.is_empty()
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<Tile> {
|
||||
self.list.pop()
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user