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:
Alejandro Alonso 2026-08-03 08:33:33 +02:00
parent 4b413299c2
commit f119b2c997
2 changed files with 432 additions and 115 deletions

View File

@ -44,6 +44,7 @@ pub(crate) use resources::RenderResources;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>; type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
#[derive(Clone, Copy)]
#[repr(u8)] #[repr(u8)]
pub enum FrameType { pub enum FrameType {
None = 0, None = 0,
@ -413,6 +414,14 @@ pub(crate) struct RenderState {
/// a tile before its text glyph uploads complete (blank first/center tile). /// a tile before its text glyph uploads complete (blank first/center tile).
/// One explicit flush warms the submit path for the rest of the pass. /// One explicit flush warms the submit path for the rest of the pass.
pub tile_atlas_flushed: bool, pub tile_atlas_flushed: 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 { pub struct InteractiveDragCrop {
@ -552,6 +561,7 @@ impl RenderState {
(width, height), (width, height),
sampling_options, sampling_options,
tiles::get_tile_dimensions(), tiles::get_tile_dimensions(),
RenderOptions::default().dpr_viewport_interest_area_threshold,
)?; )?;
Self::assemble(width, height, surfaces) Self::assemble(width, height, surfaces)
@ -596,6 +606,7 @@ impl RenderState {
preserve_target_during_render: false, preserve_target_during_render: false,
backbuffer_crop_cache: HashMap::default(), backbuffer_crop_cache: HashMap::default(),
tile_atlas_flushed: false, tile_atlas_flushed: false,
paint_region: None,
}) })
} }
@ -897,6 +908,13 @@ impl RenderState {
// affect pending_tiles generation. // affect pending_tiles generation.
self.tile_viewbox self.tile_viewbox
.set_interest(self.options.dpr_viewport_interest_area_threshold); .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,
);
} }
} }
@ -923,7 +941,11 @@ impl RenderState {
pub fn resize(&mut self, width: i32, height: i32) -> Result<()> { pub fn resize(&mut self, width: i32, height: i32) -> Result<()> {
let dpr_width = (width as f32 * self.options.dpr).floor() as i32; let dpr_width = (width as f32 * self.options.dpr).floor() as i32;
let dpr_height = (height 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.viewbox.set_wh(width as f32, height as f32);
self.tile_viewbox.update(&self.viewbox); self.tile_viewbox.update(&self.viewbox);
@ -1169,6 +1191,7 @@ impl RenderState {
// Clear dirty flags for surfaces we just cleared // Clear dirty flags for surfaces we just cleared
self.surfaces.clear_dirty(dirty_surfaces_to_clear); self.surfaces.clear_dirty(dirty_surfaces_to_clear);
} }
} }
pub fn clear_focus_mode(&mut self) { pub fn clear_focus_mode(&mut self) {
@ -2190,6 +2213,7 @@ impl RenderState {
// reorder by distance to the center. // reorder by distance to the center.
self.current_tile = None; self.current_tile = None;
self.paint_region = None;
} }
pub fn start_render_loop( pub fn start_render_loop(
@ -2200,6 +2224,7 @@ impl RenderState {
sync_render: bool, sync_render: bool,
) -> Result<FrameType> { ) -> Result<FrameType> {
self.clear(tree); self.clear(tree);
let timestamp = self.render_budget_start(timestamp);
let _start = performance::begin_timed_log!("start_render_loop"); let _start = performance::begin_timed_log!("start_render_loop");
let scale = self.get_scale(); let scale = self.get_scale();
@ -2213,6 +2238,7 @@ impl RenderState {
// Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) // 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 // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas
// growth far beyond real content. // growth far beyond real content.
let t_bounds = performance::get_time();
let doc_bounds = self.compute_document_bounds(base_object, tree); let doc_bounds = self.compute_document_bounds(base_object, tree);
self.surfaces.atlas.set_doc_bounds(doc_bounds); self.surfaces.atlas.set_doc_bounds(doc_bounds);
@ -2221,6 +2247,7 @@ impl RenderState {
self.preserve_target_during_render = false; self.preserve_target_during_render = false;
if preserve_target && self.options.is_fast_mode() { if preserve_target && self.options.is_fast_mode() {
let t_idx = performance::get_time();
self.rebuild_tile_index(tree); self.rebuild_tile_index(tree);
} }
@ -2276,6 +2303,7 @@ impl RenderState {
let _tile_start = performance::begin_timed_log!("tile_cache_update"); let _tile_start = performance::begin_timed_log!("tile_cache_update");
performance::begin_measure!("tile_cache"); performance::begin_measure!("tile_cache");
let t_pending = performance::get_time();
let only_visible = self.options.is_interactive_transform(); let only_visible = self.options.is_interactive_transform();
self.pending_tiles self.pending_tiles
.update(&self.tile_viewbox, &self.surfaces, only_visible); .update(&self.tile_viewbox, &self.surfaces, only_visible);
@ -2294,6 +2322,7 @@ impl RenderState {
// stable viewbox (e.g. recoloring) which renders in one frame. // stable viewbox (e.g. recoloring) which renders in one frame.
let allow_stop = let allow_stop =
!preserve_target || self.zoom_changed() || self.options.is_interactive_transform(); !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)?; frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
// This is an option to debug frames. // This is an option to debug frames.
@ -2358,9 +2387,14 @@ impl RenderState {
) -> Result<FrameType> { ) -> Result<FrameType> {
performance::begin_measure!("continue_render_loop"); performance::begin_measure!("continue_render_loop");
let timestamp = self.render_budget_start(timestamp); let timestamp = self.render_budget_start(timestamp);
let t0 = performance::get_time();
let pending_before = self.pending_tiles.list.len();
let frame_type = let frame_type =
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; 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 // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not
// presented (only flushed), so defer composition to the final frame and // presented (only flushed), so defer composition to the final frame and
// avoid re-snapshotting up to 4096² on every rAF during async tile work. // avoid re-snapshotting up to 4096² on every rAF during async tile work.
@ -2386,12 +2420,14 @@ impl RenderState {
// cache from the clean Backbuffer (no UI overlay yet) so that // cache from the clean Backbuffer (no UI overlay yet) so that
// interactive drag backgrounds don't include the grid overlay. // interactive drag backgrounds don't include the grid overlay.
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
let t_crop = performance::get_time();
self.rebuild_backbuffer_crop_cache(tree); self.rebuild_backbuffer_crop_cache(tree);
} }
// present_frame: copy clean Backbuffer → Target, draw UI/debug // present_frame: copy clean Backbuffer → Target, draw UI/debug
// overlays on Target only, then flush. Backbuffer stays overlay-free. // overlays on Target only, then flush. Backbuffer stays overlay-free.
self.present_frame(tree); self.present_frame(tree);
wapi::notify_tiles_render_complete!(); wapi::notify_tiles_render_complete!();
performance::end_measure!("render"); performance::end_measure!("render");
} }
} }
@ -2405,7 +2441,6 @@ impl RenderState {
tree: ShapesPoolRef, tree: ShapesPoolRef,
timestamp: i32, timestamp: i32,
) -> Result<FrameType> { ) -> Result<FrameType> {
let timestamp = self.render_budget_start(timestamp);
self.render_shape_tree_partial(base_object, tree, timestamp, false)?; self.render_shape_tree_partial(base_object, tree, timestamp, false)?;
// Same composition as `continue_render_loop` for full frames: snapshot only the // Same composition as `continue_render_loop` for full frames: snapshot only the
@ -2540,24 +2575,6 @@ impl RenderState {
Ok((data.as_bytes().to_vec(), width, height)) 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] #[inline]
pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool {
if iteration % self.options.node_batch_threshold != 0 { if iteration % self.options.node_batch_threshold != 0 {
@ -2582,6 +2599,31 @@ impl RenderState {
true 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] #[inline]
fn clip_target_surface_to_stack( fn clip_target_surface_to_stack(
&mut self, &mut self,
@ -3117,6 +3159,7 @@ impl RenderState {
/// Renders element drop shadows to DropShadows surface and composites to Current. /// 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). /// 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)] #[allow(clippy::too_many_arguments)]
fn render_element_drop_shadows_and_composite( fn render_element_drop_shadows_and_composite(
&mut self, &mut self,
@ -3546,6 +3589,7 @@ impl RenderState {
Cow::Borrowed(element) Cow::Borrowed(element)
}; };
let is_text = matches!(element_for_inline.shape_type, Type::Text(_));
self.render_shape( self.render_shape(
&element_for_inline, &element_for_inline,
clip_bounds.clone(), clip_bounds.clone(),
@ -3631,6 +3675,13 @@ impl RenderState {
} }
// We try to avoid doing too many calls to get_time // 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) { if allow_stop && self.should_stop_rendering(iteration, timestamp) {
return Ok((is_empty, true)); return Ok((is_empty, true));
} }
@ -3640,6 +3691,153 @@ impl RenderState {
Ok((is_empty, false)) 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 = "region";
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 &region_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;
let mut shape_ids: HashSet<Uuid> = HashSet::default();
let mut region_has_bg_blur = false;
for tile in &region_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 &region.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( pub fn render_shape_tree_partial(
&mut self, &mut self,
base_object: Option<&Uuid>, base_object: Option<&Uuid>,
@ -3683,31 +3881,37 @@ impl RenderState {
} }
performance::end_measure!("render_shape_tree::uncached"); performance::end_measure!("render_shape_tree::uncached");
let tile_rect = self.get_current_tile_bounds()?; if let Some(region) = self.paint_region.take() {
// Composite if the walker did work in this PAF (`!is_empty`) OR if !is_empty || self.current_tile_had_shapes {
// the tile has unfinished work from a previous PAF self.apply_paint_region_to_atlas(&region)?;
// (`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::Yes,
);
} else {
self.apply_render_to_final_canvas()?;
} }
} 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() { if self.options.is_debug_visible() {
debug::render_workspace_current_tile( debug::render_workspace_current_tile(
self, self,
"".to_string(), "".to_string(),
current_tile, current_tile,
tile_rect, tile_rect,
); );
}
} }
} }
} else if self.tiles.is_empty_at(current_tile) { } else if self.tiles.is_empty_at(current_tile) {
@ -3719,6 +3923,11 @@ impl RenderState {
.canvas(SurfaceId::Current) .canvas(SurfaceId::Current)
.clear(self.background_color); .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 // If we finish processing every node rendering is complete
// let's check if there are more pending nodes // let's check if there are more pending nodes
if let Some(next_tile) = self.pending_tiles.pop() { if let Some(next_tile) = self.pending_tiles.pop() {
@ -3732,44 +3941,47 @@ impl RenderState {
let viewer_masked_pass = self.viewer_masked_pass(); let viewer_masked_pass = self.viewer_masked_pass();
let Some(ids) = self.tiles.get_shapes_at(next_tile) else { let valid_ids = {
// If the tile is empty we do not need to render it. let Some(ids) = self.tiles.get_shapes_at(next_tile) else {
continue; // If the tile is empty we do not need to render it.
}; continue;
};
// Never skip based on cached surfaces during viewer masked passes. // Never skip based on cached surfaces during viewer masked passes.
if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) {
// If the tile is cached, then we do not need to // If the tile is cached, then we do not need to
// render it. // render it.
continue; continue;
} }
// Check if any shape on this tile has a background blur. // Check if any shape on this tile has a background blur.
// If so, we need ALL root shapes rendered (not just those // If so, we need ALL root shapes rendered (not just those
// assigned to this tile) because the blur snapshots Current // assigned to this tile) because the blur snapshots Current
// which must contain the shapes behind it. // which must contain the shapes behind it.
let tile_has_bg_blur = ids.iter().any(|id| { let tile_has_bg_blur = ids.iter().any(|id| {
tree.get(id) tree.get(id)
.is_some_and(|s| s.visible_background_blur().is_some()) .is_some_and(|s| s.visible_background_blur().is_some())
}); });
// We only need first level shapes, in the same order as the parent node. // We only need first level shapes, in the same order as the parent node.
// //
// During interactive transforms we may invalidate only the modified shapes // During interactive transforms we may invalidate only the modified shapes
// (to avoid massive ancestor eviction). However, we still composite full // (to avoid massive ancestor eviction). However, we still composite full
// tiles (we clear the tile rect before drawing Current), so we must render // tiles (we clear the tile rect before drawing Current), so we must render
// all root shapes that can contribute to this tile; otherwise, unchanged // all root shapes that can contribute to this tile; otherwise, unchanged
// siblings inside the same tile would disappear. // siblings inside the same tile would disappear.
let mut valid_ids = Vec::with_capacity(ids.len()); let mut valid_ids = Vec::with_capacity(ids.len());
if self.options.is_interactive_transform() || tile_has_bg_blur { if self.options.is_interactive_transform() || tile_has_bg_blur {
valid_ids.extend(root_ids.iter().copied()); valid_ids.extend(root_ids.iter().copied());
} else { } else {
for root_id in root_ids.iter() { for root_id in root_ids.iter() {
if ids.contains(root_id) { if ids.contains(root_id) {
valid_ids.push(*root_id); valid_ids.push(*root_id);
}
} }
} }
} valid_ids
};
if !valid_ids.is_empty() { if !valid_ids.is_empty() {
self.current_tile_had_shapes = true; self.current_tile_had_shapes = true;
@ -3978,11 +4190,14 @@ impl RenderState {
pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) {
let zoom_changed = self.zoom_changed(); let zoom_changed = self.zoom_changed();
performance::begin_measure!("rebuild_tile_index"); performance::begin_measure!("rebuild_tile_index");
let t0 = performance::get_time();
let mut shapes_visited = 0u32;
let mut nodes = Vec::<Uuid>::with_capacity(64); let mut nodes = Vec::<Uuid>::with_capacity(64);
nodes.push(Uuid::nil()); nodes.push(Uuid::nil());
while let Some(shape_id) = nodes.pop() { while let Some(shape_id) = nodes.pop() {
if let Some(shape) = tree.get(&shape_id) { if let Some(shape) = tree.get(&shape_id) {
if shape_id != Uuid::nil() { if shape_id != Uuid::nil() {
shapes_visited += 1;
if zoom_changed { if zoom_changed {
let _ = self.update_shape_tiles(shape, tree); let _ = self.update_shape_tiles(shape, tree);
} else { } else {

View File

@ -14,11 +14,12 @@ use std::collections::{HashMap, HashSet};
const TEXTURES_CACHE_CAPACITY: usize = 1024; const TEXTURES_CACHE_CAPACITY: usize = 1024;
const TEXTURES_BATCH_DELETE: usize = 256; 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. // Per-tile blur/shadow spill into the paint surface. Also used as the
// If it's too big it could affect performance. // outer pad when Current covers a multi-tile region.
const TILE_SIZE: i32 = tiles::TILE_SIZE as i32; const TILE_SIZE: i32 = tiles::TILE_SIZE as i32;
const TILE_SIZE_MULTIPLIER: i32 = 2; const TILE_SIZE_MULTIPLIER: i32 = 2;
const TILE_MARGIN_SIZE: i32 = TILE_SIZE * TILE_SIZE_MULTIPLIER / 4; 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 { const TILE_DRAWABLE_RECT: IRect = IRect {
left: TILE_MARGIN_SIZE, left: TILE_MARGIN_SIZE,
top: TILE_MARGIN_SIZE, top: TILE_MARGIN_SIZE,
@ -27,6 +28,21 @@ const TILE_DRAWABLE_RECT: IRect = IRect {
}; };
const DOC_ATLAS_MAX_DIM: i32 = 4096; 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 /// GPU→GPU copy of `src` from `from` into `dst` on `to_canvas`, without
/// `image_snapshot` (avoids per-tile sync stalls on WebGL). /// `image_snapshot` (avoids per-tile sync stalls on WebGL).
fn draw_surface_src_rect_to_dst( fn draw_surface_src_rect_to_dst(
@ -463,14 +479,16 @@ impl Surfaces {
(width, height): (i32, i32), (width, height): (i32, i32),
sampling_options: skia::SamplingOptions, sampling_options: skia::SamplingOptions,
tile_dims: skia::ISize, tile_dims: skia::ISize,
interest_tiles: i32,
) -> Result<Self> { ) -> Result<Self> {
let gpu_state = get_gpu_state(); let gpu_state = get_gpu_state();
let max_texture_size = gpu_state.max_texture_size();
let extra_tile_dims = skia::ISize::new( // Current + layers cover viewport + interest + blur margins so a
tile_dims.width * TILE_SIZE_MULTIPLIER, // paint-once region pass needs no surface recreate on zoom.
tile_dims.height * TILE_SIZE_MULTIPLIER, let extra_tile_dims =
); paint_surface_dims(width, height, interest_tiles, max_texture_size);
let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); let margins = skia::ISize::new(TILE_MARGIN_SIZE, TILE_MARGIN_SIZE);
let target = gpu_state.create_target_surface(width, height)?; let target = gpu_state.create_target_surface(width, height)?;
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?;
@ -478,7 +496,6 @@ impl Surfaces {
let backbuffer = let backbuffer =
gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; 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( let tile_atlas = gpu_state.create_surface_with_dimensions(
"tile_atlas".to_string(), "tile_atlas".to_string(),
max_texture_size, max_texture_size,
@ -498,7 +515,7 @@ impl Surfaces {
gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?; gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?;
let shape_strokes = let shape_strokes =
gpu_state.create_surface_with_isize("shape_strokes".to_string(), extra_tile_dims)?; 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 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)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?;
@ -665,13 +682,67 @@ impl Surfaces {
self.margins 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(); let gpu_state = get_gpu_state();
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?; self.reset_from_target(gpu_state.create_target_surface(new_width, new_height)?)?;
self.resize_paint_surfaces(new_width, new_height, interest_tiles)?;
Ok(()) 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 { pub fn snapshot(&mut self, id: SurfaceId) -> skia::Image {
let surface = self.get_mut(id); let surface = self.get_mut(id);
surface.image_snapshot() surface.image_snapshot()
@ -1211,9 +1282,28 @@ impl Surfaces {
tile_rect: &skia::Rect, tile_rect: &skia::Rect,
skip_cache_surface: bool, skip_cache_surface: bool,
tile_doc_rect: skia::Rect, 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 gpu_state = get_gpu_state();
let src = skia::Rect::from(TILE_DRAWABLE_RECT);
let sampling = self.sampling_options; let sampling = self.sampling_options;
// DocAtlas + tile atlas via Surface::draw (no image_snapshot sync). // DocAtlas + tile atlas via Surface::draw (no image_snapshot sync).
@ -1245,6 +1335,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 { pub fn has_cached_tile_surface(&self, tile: Tile) -> bool {
self.tiles.has(tile) self.tiles.has(tile)
} }
@ -1377,42 +1488,33 @@ impl Surfaces {
draw_on_cache: DrawOnCache, draw_on_cache: DrawOnCache,
) { ) {
let sampling_options = self.sampling_options; let sampling_options = self.sampling_options;
let src_rect = IRect::from_xywh( // Current is viewport-sized for paint-once regions. During interactive
self.margins.width, // drag we still paint one tile into the top-left padded slot; blit only
self.margins.height, // that slot or we wipe the rest of Target with cleared Current pixels.
self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, let pad = (TILE_SIZE + 2 * TILE_MARGIN_SIZE) as f32;
self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, 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_surface_src_rect_to_dst(
&mut self.current,
// Draw background self.backbuffer.canvas(),
// let mut paint = skia::Paint::default(); src,
// paint.set_color(color); dst,
// 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,
),
sampling_options, sampling_options,
None,
); );
// Also draw to cache for render_from_cache
if draw_on_cache == DrawOnCache::Yes { if draw_on_cache == DrawOnCache::Yes {
self.current.draw( draw_surface_src_rect_to_dst(
&mut self.current,
self.cache.canvas(), self.cache.canvas(),
( src,
tile_rect.left - src_rect_f.left, dst,
tile_rect.top - src_rect_f.top,
),
sampling_options, sampling_options,
None,
); );
} }
} }