mirror of
https://github.com/penpot/penpot.git
synced 2026-08-05 12:29:00 +00:00
WIP
This commit is contained in:
parent
379eb9b343
commit
5f921ecb9e
@ -22,4 +22,14 @@
|
||||
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
|
||||
- Plain viewport fast mode (`options.is_viewport_interaction()`) renders from cache and does not flush target output inside `process_animation_frame`; interactive transforms do flush.
|
||||
- Zoom changes rebuild the tile index while preserving cached tile textures. Avoid replacing that path with shallow rebuilds if blur/shadow cache preservation matters.
|
||||
- Tile texture raster size follows content quality: interactive pan/zoom refill uses `512` px (DPR=1 fill-rate) with **one paint-once tree walk**; if view DPR > 1, a second **per-tile** pass promotes to `512 * dpr`. No banding. World tile size stays `512/zoom`. Compositing uses device-scaled `RSXform` so 512 px sprites cover `512*dpr` backbuffer cells.
|
||||
- **Critical:** paint CTM must use `get_raster_scale()` / `get_paint_scale()` (`raster_px / world_tile` = `zoom` at interactive LOD), including paint-once. Soft settle at view scale (`zoom*dpr`) re-introduces the DPR=2 zoom hitch.
|
||||
- Soft settle presents and **stays Interactive** (no auto sharp). ZOOM-PERF showed sharp per-tile rewalk (~15× tree at 1024²) dominating ~2s after zoom; 512px sprites already cover the HiDPI backbuffer via `RSXform`. Re-enable sharp only via an explicit idle path.
|
||||
- Partial frames **skip** GPU flush entirely. On WASM, `context.flush(None)` still blocked ~470ms after paint-once DONE (same class as `flush_and_submit`); hard sync only in `present_frame` / ViewportReady. Mid-walk soft flush is also skipped while `paint_region` is active.
|
||||
- With full-quality `tile_size_px=1024` a 4096² tile atlas has only **16 slots**. `TileTextureCache::add` must never panic: reuse existing slots, `clear()` must deallocate immediately (not only mark `removed`), force-evict non-visible, and if still full skip the atlas blit. Interest tiles without a slot go into `rendered_without_slot` so the pending scheduler does not loop; `has()` stays false until they get a real sprite (so pan into them re-renders). Visible overflow steals the farthest other visible slot. Interactive quality (512 px) restores ~64 slots.
|
||||
- Viewport interest ring is in tile units and does **not** scale with DPR (stays 1 by default). Scaling it with DPR after DPR-sized tiles doubled the device-pixel margin and ballooned the cache surface (~216 MiB).
|
||||
- During interactive content quality, pending tiles are **visible-only** (skip interest ring) so the soft settle pass finishes quickly; interest fills on the full-quality pass.
|
||||
- `rebuild_backbuffer_crop_cache` caps crop windows to the backbuffer size (not `max_texture_size`) so crops use cheap Backbuffer `snapshot_rect`; doc-atlas snapshot + scratch are lazy and only for off-viewport fallback. Avoid eager 4096² scratch after pan/zoom Full frames.
|
||||
- Cache surface growth must compare against the live `cache` width/height, not `get_cache_size(cached_viewbox)`. The latter re-created the same large cache on every progressive frame while `cached_viewbox` lagged.
|
||||
- `start_render_loop` always sets `allow_stop=true` so tile work yields under `max_blocking_time_ms`. Do not gate yielding on `preserve_target && !zoom_changed`: that forced a full interest-area sync pass after pan and froze HiDPI deep-zoom sessions. `preserve_target` alone keeps the last frame visible during progressive fill-in.
|
||||
- Pending tile priority is intentionally reversed by pop order; check the queue construction before changing tile scheduling.
|
||||
@ -371,17 +371,24 @@ pub extern "C" fn set_view_end() -> Result<()> {
|
||||
// index and clear the tile texture cache, but *preserve*
|
||||
// the cache canvas so render_from_cache can show a scaled
|
||||
// preview of the old content while new tiles render.
|
||||
//
|
||||
// At HiDPI, refill first at interactive (512 px) quality so
|
||||
// progressive fill-rate matches DPR=1, then promote to sharp.
|
||||
let t0 = performance::get_time();
|
||||
render_state.enter_interactive_content_quality()?;
|
||||
let t_idx = performance::get_time();
|
||||
render_state.rebuild_tile_index(&state.shapes);
|
||||
render_state.zoom_perf_log(&format!(
|
||||
"set_view_end.rebuild_tile_index {}ms",
|
||||
performance::get_time() - t0
|
||||
"set_view_end.rebuild_tile_index {}ms (after enter_interactive {}ms)",
|
||||
performance::get_time() - t_idx,
|
||||
t_idx - t0
|
||||
));
|
||||
let t1 = performance::get_time();
|
||||
render_state.surfaces.invalidate_tile_cache();
|
||||
render_state.zoom_perf_log(&format!(
|
||||
"set_view_end.invalidate_tile_cache {}ms",
|
||||
performance::get_time() - t1
|
||||
"set_view_end.invalidate_tile_cache {}ms | {}",
|
||||
performance::get_time() - t1,
|
||||
render_state.surfaces.surfaces_size_summary()
|
||||
));
|
||||
} else {
|
||||
// Pure pan at the same zoom level: tile contents have not
|
||||
|
||||
@ -22,7 +22,7 @@ use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use options::RenderOptions;
|
||||
use options::{ContentQuality, RenderOptions};
|
||||
pub use surfaces::{SurfaceId, Surfaces};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
@ -453,6 +453,12 @@ pub(crate) struct RenderState {
|
||||
zoom_perf_layered_other_n: u32,
|
||||
/// Multi-tile paint-once into Current, then crop to atlas slots.
|
||||
paint_region: Option<PaintRegion>,
|
||||
/// After soft (DPR≤1 fill-rate) settle, refill sharp tiles one-by-one at
|
||||
/// full view DPR. Paint-once is disabled until this pass finishes.
|
||||
sharp_tile_refill: bool,
|
||||
/// Soft settle presented; begin sharp refill on the *next* continue so the
|
||||
/// browser gets a frame to stay responsive before per-tile HiDPI work.
|
||||
pending_sharp_promote: bool,
|
||||
}
|
||||
|
||||
/// Active paint-once region (visible viewport tiles or interest ring).
|
||||
@ -473,13 +479,18 @@ pub struct InteractiveDragCrop {
|
||||
pub image: skia::Image,
|
||||
}
|
||||
|
||||
/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side
|
||||
/// at most `max_side_px` (**without scaling**): centered on the projection of
|
||||
/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty.
|
||||
/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas).
|
||||
/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)`
|
||||
/// with width/height at most `max_w_px` / `max_h_px` (**without scaling**): centered on
|
||||
/// the projection of `viewport_doc ∩ src_doc_bounds`, or on the full crop if that
|
||||
/// intersection is empty.
|
||||
///
|
||||
/// Caps should match the backbuffer (viewport) size so the common path can
|
||||
/// `snapshot_rect` from Backbuffer instead of allocating a max-texture scratch
|
||||
/// and snapshotting the document atlas.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn drag_crop_snapshot_window_px(
|
||||
max_side_px: i32,
|
||||
max_w_px: i32,
|
||||
max_h_px: i32,
|
||||
out_w: i32,
|
||||
out_h: i32,
|
||||
viewport_doc: Rect,
|
||||
@ -490,12 +501,13 @@ fn drag_crop_snapshot_window_px(
|
||||
src_top_px: i32,
|
||||
src_doc_bounds: Rect,
|
||||
) -> (i32, i32, i32, i32) {
|
||||
let cap = max_side_px.max(1);
|
||||
if out_w <= cap && out_h <= cap {
|
||||
let cap_w = max_w_px.max(1);
|
||||
let cap_h = max_h_px.max(1);
|
||||
if out_w <= cap_w && out_h <= cap_h {
|
||||
return (0, 0, out_w, out_h);
|
||||
}
|
||||
let win_w = out_w.min(cap);
|
||||
let win_h = out_h.min(cap);
|
||||
let win_w = out_w.min(cap_w);
|
||||
let win_h = out_h.min(cap_h);
|
||||
|
||||
let mut vis = viewport_doc;
|
||||
let has_vis = vis.intersect(src_doc_bounds);
|
||||
@ -597,7 +609,7 @@ impl RenderState {
|
||||
let surfaces = Surfaces::try_new(
|
||||
(width, height),
|
||||
sampling_options,
|
||||
tiles::get_tile_dimensions(),
|
||||
tiles::get_tile_dimensions(1.0),
|
||||
RenderOptions::default().dpr_viewport_interest_area_threshold,
|
||||
)?;
|
||||
|
||||
@ -669,6 +681,8 @@ impl RenderState {
|
||||
zoom_perf_layered_nested_n: 0,
|
||||
zoom_perf_layered_other_n: 0,
|
||||
paint_region: None,
|
||||
sharp_tile_refill: false,
|
||||
pending_sharp_promote: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -739,13 +753,15 @@ impl RenderState {
|
||||
self.zoom_perf_frame = 0;
|
||||
self.surfaces.set_paint_diag(true);
|
||||
println!(
|
||||
"[ZOOM-PERF] BEGIN {reason} zoom={:.4} vbox=({:.1},{:.1},{:.1}x{:.1}) {}",
|
||||
"[ZOOM-PERF] BEGIN {reason} zoom={:.4} dpr={:.2} quality={:?} vbox=({:.1},{:.1},{:.1}x{:.1}) | {}",
|
||||
self.viewbox.zoom,
|
||||
self.options.dpr,
|
||||
self.options.content_quality(),
|
||||
self.viewbox.area.left,
|
||||
self.viewbox.area.top,
|
||||
self.viewbox.area.width(),
|
||||
self.viewbox.area.height(),
|
||||
self.surfaces.paint_diag_summary()
|
||||
self.surfaces.surfaces_size_summary()
|
||||
);
|
||||
}
|
||||
|
||||
@ -1037,17 +1053,106 @@ impl RenderState {
|
||||
if self.options.set_dpr(dpr) {
|
||||
self.tile_viewbox
|
||||
.set_interest(self.options.dpr_viewport_interest_area_threshold);
|
||||
get_resources().fonts.set_scale_debug_font(dpr);
|
||||
self.viewbox.set_dpr(dpr);
|
||||
self.cached_viewbox.set_dpr(dpr);
|
||||
self.surfaces.set_view_dpr(dpr)?;
|
||||
self.surfaces
|
||||
.set_raster_tile_size(self.options.raster_tile_size_px())?;
|
||||
self.resize(
|
||||
self.viewbox.width().floor() as i32,
|
||||
self.viewbox.height().floor() as i32,
|
||||
)?;
|
||||
get_resources().fonts.set_scale_debug_font(dpr);
|
||||
self.viewbox.set_dpr(dpr);
|
||||
self.surfaces.set_dpr(dpr);
|
||||
self.tiles.invalidate();
|
||||
self.tile_viewbox.update(&self.viewbox);
|
||||
// First paint at DPR>1 should be the soft (DPR≤1 fill-rate) pass.
|
||||
self.enter_interactive_content_quality()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop to interactive (512 px) tile raster when view DPR > 1 so zoom
|
||||
/// refill fill-rate matches DPR=1. No-op when already interactive or at DPR≈1.
|
||||
pub fn enter_interactive_content_quality(&mut self) -> Result<()> {
|
||||
if self.options.dpr <= 1.05 {
|
||||
return Ok(());
|
||||
}
|
||||
if !self
|
||||
.options
|
||||
.set_content_quality(ContentQuality::Interactive)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = performance::get_time();
|
||||
self.surfaces
|
||||
.set_raster_tile_size(self.options.raster_tile_size_px())?;
|
||||
self.surfaces.invalidate_tile_cache();
|
||||
self.sharp_tile_refill = false;
|
||||
self.pending_sharp_promote = false;
|
||||
self.paint_region = None;
|
||||
self.zoom_perf_log(&format!(
|
||||
"enter_interactive_content_quality {}ms tile_px={} | {}",
|
||||
performance::get_time() - t0,
|
||||
self.surfaces.tile_size_px(),
|
||||
self.surfaces.surfaces_size_summary()
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promote to full-DPR tile textures after an interactive settle. Returns
|
||||
/// `true` when a new progressive pass is required.
|
||||
///
|
||||
/// The sharp refill is intentionally **per-tile** (see `sharp_tile_refill`):
|
||||
/// soft settle already walked the tree once at DPR≤1 fill-rate.
|
||||
pub fn try_begin_full_quality_pass(&mut self, _tree: ShapesPoolRef) -> Result<bool> {
|
||||
if !self.options.needs_full_quality_upgrade() {
|
||||
return Ok(false);
|
||||
}
|
||||
let t0 = performance::get_time();
|
||||
self.options.set_content_quality(ContentQuality::Full);
|
||||
let t_raster = performance::get_time();
|
||||
self.surfaces
|
||||
.set_raster_tile_size(self.options.raster_tile_size_px())?;
|
||||
let raster_ms = performance::get_time() - t_raster;
|
||||
let t_inv = performance::get_time();
|
||||
self.surfaces.invalidate_tile_cache();
|
||||
let inv_ms = performance::get_time() - t_inv;
|
||||
let scale = self.get_raster_scale();
|
||||
let surface_ids = SurfaceId::Strokes as u32
|
||||
| SurfaceId::Fills as u32
|
||||
| SurfaceId::InnerShadows as u32
|
||||
| SurfaceId::TextDropShadows as u32;
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().scale((scale, scale));
|
||||
});
|
||||
self.tile_viewbox.update(&self.viewbox);
|
||||
let t_pend = performance::get_time();
|
||||
// Visible only — interest can wait; keeps the sharp refill from
|
||||
// monopolizing the main thread after zoom.
|
||||
self.pending_tiles
|
||||
.update(&self.tile_viewbox, &self.surfaces, true);
|
||||
let pend_ms = performance::get_time() - t_pend;
|
||||
self.current_tile = None;
|
||||
self.pending_nodes.clear();
|
||||
self.paint_region = None;
|
||||
self.cache_cleared_this_render = false;
|
||||
self.preserve_target_during_render = true;
|
||||
self.viewport_presented = false;
|
||||
self.sharp_tile_refill = true;
|
||||
self.pending_sharp_promote = false;
|
||||
self.zoom_perf_log(&format!(
|
||||
"try_begin_full_quality_pass total={}ms raster={}ms invalidate={}ms pending={}ms pending_n={} scale={:.3} | {}",
|
||||
performance::get_time() - t0,
|
||||
raster_ms,
|
||||
inv_ms,
|
||||
pend_ms,
|
||||
self.pending_tiles.list.len(),
|
||||
scale,
|
||||
self.surfaces.surfaces_size_summary()
|
||||
));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_antialias_threshold(&mut self, value: f32) {
|
||||
self.options.set_antialias_threshold(value);
|
||||
}
|
||||
@ -1117,8 +1222,19 @@ impl RenderState {
|
||||
/// on top of Target, then present. Backbuffer is left clean so it can be reused
|
||||
/// as-is across interactive-transform frames without stale overlay pixels.
|
||||
pub fn present_frame(&mut self, tree: ShapesPoolRef) {
|
||||
let t0 = performance::get_time();
|
||||
self.compose_frame(tree);
|
||||
let t_compose = performance::get_time();
|
||||
self.surfaces.flush_and_submit(SurfaceId::Target);
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_log(&format!(
|
||||
"present_frame compose={}ms flush_submit={}ms total={}ms | {}",
|
||||
t_compose - t0,
|
||||
performance::get_time() - t_compose,
|
||||
performance::get_time() - t0,
|
||||
self.surfaces.surfaces_size_summary()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose the frame on Target — the already-rendered Backbuffer plus the
|
||||
@ -1542,14 +1658,15 @@ impl RenderState {
|
||||
|
||||
// Only perceptible shadows need the layered Fills/Strokes path. Use the
|
||||
// same footprint LOD as when painting drop and inner shadows.
|
||||
let scale = self.get_scale();
|
||||
let view_scale = self.get_scale();
|
||||
let paint_scale = self.get_paint_scale();
|
||||
let shadows_need_layered = !skip_drop_shadows
|
||||
&& (shape
|
||||
.drop_shadows_visible()
|
||||
.any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive()))
|
||||
.any(|s| s.is_perceptible_at_scale_for(view_scale, shape.is_recursive()))
|
||||
|| shape
|
||||
.inner_shadows_visible()
|
||||
.any(|s| s.is_perceptible_at_scale_for(scale, shape.is_recursive())));
|
||||
.any(|s| s.is_perceptible_at_scale_for(view_scale, shape.is_recursive())));
|
||||
|
||||
// Clip is allowed: we apply the same stack on Current after scale+translate.
|
||||
// Opacity < 1 with SrcOver is OK: render_shape_enter already opened a
|
||||
@ -1580,17 +1697,17 @@ impl RenderState {
|
||||
if can_render_directly {
|
||||
let translation = self
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
.get_render_context_translation(self.render_area, paint_scale);
|
||||
|
||||
self.surfaces.apply_mut(target_surface as u32, |s| {
|
||||
let canvas = s.canvas();
|
||||
canvas.save();
|
||||
canvas.scale((scale, scale));
|
||||
canvas.scale((paint_scale, paint_scale));
|
||||
canvas.translate(translation);
|
||||
});
|
||||
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
self.apply_clip_stack_to_surfaces(clips, target_surface as u32, scale, None);
|
||||
self.apply_clip_stack_to_surfaces(clips, target_surface as u32, paint_scale, None);
|
||||
}
|
||||
|
||||
if !shape.transform.is_identity() {
|
||||
@ -2235,18 +2352,20 @@ impl RenderState {
|
||||
|
||||
pub fn update_render_context(&mut self, tile: tiles::Tile) {
|
||||
self.current_tile = Some(tile);
|
||||
let scale = self.get_scale();
|
||||
self.render_area = tiles::get_tile_rect(tile, scale);
|
||||
let view_scale = self.get_scale();
|
||||
let raster_scale = self.get_raster_scale();
|
||||
self.render_area = tiles::get_tile_rect(tile, view_scale, self.viewbox.dpr);
|
||||
let margins = self.surfaces.margins();
|
||||
let margin_w = margins.width as f32 / scale;
|
||||
let margin_h = margins.height as f32 / scale;
|
||||
let margin_w = margins.width as f32 / raster_scale;
|
||||
let margin_h = margins.height as f32 / raster_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);
|
||||
self.surfaces
|
||||
.update_render_context(self.render_area, raster_scale);
|
||||
}
|
||||
|
||||
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||
@ -2325,19 +2444,22 @@ impl RenderState {
|
||||
})
|
||||
.collect();
|
||||
|
||||
if non_overlapping.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let vb_left = self.viewbox.area.left;
|
||||
let vb_top = self.viewbox.area.top;
|
||||
let (bb_w, bb_h) = self.surfaces.surface_size(SurfaceId::Backbuffer);
|
||||
let max_snap_px = get_gpu_state().max_texture_size();
|
||||
let max_tex = get_gpu_state().max_texture_size();
|
||||
// Prefer viewport-sized crops so we can snapshot from Backbuffer (cheap)
|
||||
// instead of allocating up to max_texture scratch + doc-atlas snapshot.
|
||||
let max_w_px = bb_w.min(max_tex).max(1);
|
||||
let max_h_px = bb_h.min(max_tex).max(1);
|
||||
|
||||
// Snapshot the atlas once for the whole pass so that all shapes sharing
|
||||
// the tile/atlas fallback path reuse the same GPU image rather than each
|
||||
// triggering a separate `image_snapshot` flush.
|
||||
let atlas_snap = self.surfaces.atlas.snapshot_for_drag_crop();
|
||||
|
||||
// Scratch surface reused across all shapes that need the tile/atlas
|
||||
// fallback — avoids one WebGL texture allocation per shape.
|
||||
// Created lazily on first use and grown if a later shape needs more space.
|
||||
// Lazily snapshot the doc atlas only if a shape falls outside the
|
||||
// backbuffer and needs the tile/atlas fallback path.
|
||||
let mut atlas_snap: Option<(skia::Image, f32, skia::Point)> = None;
|
||||
let mut scratch_surface: Option<skia::Surface> = None;
|
||||
|
||||
for (id, doc_bounds, selrect) in non_overlapping {
|
||||
@ -2360,7 +2482,8 @@ impl RenderState {
|
||||
let full_w = src_irect.width();
|
||||
let full_h = src_irect.height();
|
||||
let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px(
|
||||
max_snap_px,
|
||||
max_w_px,
|
||||
max_h_px,
|
||||
full_w,
|
||||
full_h,
|
||||
viewport,
|
||||
@ -2400,6 +2523,9 @@ impl RenderState {
|
||||
let image = if let Some(img) = backbuffer_snap {
|
||||
img
|
||||
} else {
|
||||
if atlas_snap.is_none() {
|
||||
atlas_snap = self.surfaces.atlas.snapshot_for_drag_crop();
|
||||
}
|
||||
// Ensure the scratch surface is large enough for this window.
|
||||
// Grow (reallocate) only when necessary so that the common case
|
||||
// of similarly-sized shapes pays zero extra allocation cost.
|
||||
@ -2525,7 +2651,7 @@ impl RenderState {
|
||||
let timestamp = self.render_budget_start(timestamp);
|
||||
|
||||
let _start = performance::begin_timed_log!("start_render_loop");
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
|
||||
self.tile_viewbox.update(&self.viewbox);
|
||||
self.focus_mode.reset();
|
||||
@ -2619,7 +2745,9 @@ impl RenderState {
|
||||
|
||||
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.options.content_quality() == ContentQuality::Interactive
|
||||
|| self.sharp_tile_refill;
|
||||
self.pending_tiles
|
||||
.update(&self.tile_viewbox, &self.surfaces, only_visible);
|
||||
if zoom_perf {
|
||||
@ -2643,10 +2771,10 @@ impl RenderState {
|
||||
if sync_render {
|
||||
frame_type = self.render_shape_tree_sync(base_object, tree, timestamp)?;
|
||||
} else {
|
||||
// Keep progressive yielding, except for a localized shape edit on a
|
||||
// stable viewbox (e.g. recoloring) which renders in one frame.
|
||||
let allow_stop =
|
||||
!preserve_target || self.zoom_changed() || self.options.is_interactive_transform();
|
||||
// Always allow progressive yielding. `preserve_target` keeps the last
|
||||
// frame visible while tiles fill in. Gating allow_stop on preserve_target
|
||||
// forced a synchronous interest-area pass after pan and blocked HiDPI.
|
||||
let allow_stop = true;
|
||||
let t_cont = performance::get_time();
|
||||
frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
|
||||
if zoom_perf {
|
||||
@ -2719,11 +2847,37 @@ impl RenderState {
|
||||
) -> Result<FrameType> {
|
||||
performance::begin_measure!("continue_render_loop");
|
||||
let timestamp = self.render_budget_start(timestamp);
|
||||
|
||||
// Soft already on screen: promote on a fresh rAF, then yield once more
|
||||
// so the first sharp tile does not share the frame with atlas teardown.
|
||||
if self.pending_sharp_promote {
|
||||
self.pending_sharp_promote = false;
|
||||
self.try_begin_full_quality_pass(tree)?;
|
||||
crate::get_gpu_state().context.flush(None);
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_log("soft→sharp promote (deferred frame)");
|
||||
}
|
||||
performance::end_measure!("continue_render_loop");
|
||||
return Ok(FrameType::Partial);
|
||||
}
|
||||
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_frame += 1;
|
||||
}
|
||||
let t0 = performance::get_time();
|
||||
let pending_before = self.pending_tiles.list.len();
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_log(&format!(
|
||||
"frame#{} continue enter pending={} sharp={} promote_pending={} quality={:?} budget={}ms | {}",
|
||||
self.zoom_perf_frame,
|
||||
pending_before,
|
||||
self.sharp_tile_refill,
|
||||
self.pending_sharp_promote,
|
||||
self.options.content_quality(),
|
||||
self.render_time_budget_ms(),
|
||||
self.surfaces.surfaces_size_summary()
|
||||
));
|
||||
}
|
||||
|
||||
let frame_type =
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?;
|
||||
@ -2764,21 +2918,40 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Soft settle at DPR≤1 fill-rate is enough for responsive zoom.
|
||||
// Auto sharp (per-tile rewalk at 1024²) took ~2s in ZOOM-PERF and froze
|
||||
// the frontend; 512px sprites are already placed with RSXform×dpr.
|
||||
// Stay Interactive until an explicit idle/sharp path is added later.
|
||||
if !self.options.is_interactive_transform()
|
||||
&& matches!(frame_type, FrameType::Full | FrameType::ViewportReady)
|
||||
&& self.options.needs_full_quality_upgrade()
|
||||
{
|
||||
self.present_frame(tree);
|
||||
self.viewport_presented = true;
|
||||
wapi::notify_tiles_render_complete!();
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_log(
|
||||
"soft settle complete — skip sharp promote (stay Interactive)",
|
||||
);
|
||||
self.zoom_perf_end("soft settle (no sharp)");
|
||||
}
|
||||
performance::end_measure!("continue_render_loop");
|
||||
return Ok(FrameType::Full);
|
||||
}
|
||||
|
||||
match frame_type {
|
||||
FrameType::None => {
|
||||
panic!("FrameType::None");
|
||||
}
|
||||
FrameType::Partial => {
|
||||
// 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();
|
||||
// Do not flush here. On WASM, `context.flush(None)` still waited
|
||||
// ~470ms after paint-once DONE (same as flush_and_submit). The
|
||||
// intentional Partial yield after DONE only helps if we return
|
||||
// immediately; hard sync belongs in present / ViewportReady.
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_log(&format!(
|
||||
"frame#{} Partial.flush_and_submit {}ms",
|
||||
self.zoom_perf_frame,
|
||||
performance::get_time() - t_f
|
||||
"frame#{} Partial.flush(skip) 0ms",
|
||||
self.zoom_perf_frame
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -2811,6 +2984,7 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
FrameType::Full => {
|
||||
self.sharp_tile_refill = false;
|
||||
if !self.viewport_presented {
|
||||
// A full-quality frame is now complete (no early viewport
|
||||
// present). Rebuild crop cache and present.
|
||||
@ -2970,10 +3144,10 @@ impl RenderState {
|
||||
// Restore render-surface transforms for the workspace context.
|
||||
// If we have a current tile, restore its tile render context; otherwise
|
||||
// fall back to restoring the previous render_area (may be empty).
|
||||
let workspace_scale = self.get_scale();
|
||||
if let Some(tile) = self.current_tile {
|
||||
self.update_render_context(tile);
|
||||
} else if !self.render_area.is_empty() {
|
||||
let workspace_scale = self.get_paint_scale();
|
||||
self.surfaces
|
||||
.update_render_context(self.render_area, workspace_scale);
|
||||
}
|
||||
@ -2986,12 +3160,7 @@ impl RenderState {
|
||||
if iteration % self.options.node_batch_threshold != 0 {
|
||||
return false;
|
||||
}
|
||||
// Multi-tile paint regions need fewer yields to finish visible HQ work.
|
||||
let budget = if self.paint_region.is_some() {
|
||||
self.options.max_blocking_time_ms.max(48)
|
||||
} else {
|
||||
self.options.max_blocking_time_ms
|
||||
};
|
||||
let budget = self.render_time_budget_ms();
|
||||
if performance::get_time() - timestamp <= budget {
|
||||
return false;
|
||||
}
|
||||
@ -3011,6 +3180,17 @@ impl RenderState {
|
||||
true
|
||||
}
|
||||
|
||||
/// Per-frame CPU budget for progressive tile work.
|
||||
#[inline]
|
||||
fn render_time_budget_ms(&self) -> i32 {
|
||||
// Sharp HiDPI tiles are expensive (1024² at DPR=2); keep slices short
|
||||
// so the frontend stays responsive after zoom.
|
||||
if self.sharp_tile_refill {
|
||||
return self.options.max_blocking_time_ms.min(8).max(4);
|
||||
}
|
||||
self.options.max_blocking_time_ms
|
||||
}
|
||||
|
||||
/// Skip all drop/inner shadows in fast mode, or when even a large design-space
|
||||
/// shadow would be subpixel. Otherwise filter per shadow via
|
||||
/// [`Shadow::is_perceptible_at_scale_for`] (stricter for recursive shapes).
|
||||
@ -3301,7 +3481,7 @@ impl RenderState {
|
||||
.current_tile
|
||||
.ok_or(Error::CriticalError("Current tile not found".to_string()))?;
|
||||
let offset = self.viewbox.get_offset();
|
||||
Ok(tile.get_rect_with_offset(&offset))
|
||||
Ok(tile.get_rect_with_offset(&offset, tiles::device_tile_size_px(self.viewbox.dpr)))
|
||||
}
|
||||
|
||||
pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect {
|
||||
@ -3329,15 +3509,16 @@ impl RenderState {
|
||||
|
||||
pub fn get_aligned_tile_bounds(&mut self, tile: tiles::Tile) -> Rect {
|
||||
let scale = self.get_scale();
|
||||
let device_tile = tiles::device_tile_size_px(self.viewbox.dpr);
|
||||
let start_tile_x =
|
||||
(self.viewbox.area.left * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE;
|
||||
(self.viewbox.area.left * scale / device_tile).floor() * device_tile;
|
||||
let start_tile_y =
|
||||
(self.viewbox.area.top * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE;
|
||||
(self.viewbox.area.top * scale / device_tile).floor() * device_tile;
|
||||
Rect::from_xywh(
|
||||
(tile.x() as f32 * tiles::TILE_SIZE) - start_tile_x,
|
||||
(tile.y() as f32 * tiles::TILE_SIZE) - start_tile_y,
|
||||
tiles::TILE_SIZE,
|
||||
tiles::TILE_SIZE,
|
||||
(tile.x() as f32 * device_tile) - start_tile_x,
|
||||
(tile.y() as f32 * device_tile) - start_tile_y,
|
||||
device_tile,
|
||||
device_tile,
|
||||
)
|
||||
}
|
||||
|
||||
@ -4185,13 +4366,14 @@ 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();
|
||||
}
|
||||
// Kick GPU work while the walker continues. Skip during paint-once:
|
||||
// on WASM `flush(None)` still syncs and the final Partial paid ~470ms;
|
||||
// mid-chunk waits also inflate walker time. Present flushes instead.
|
||||
if allow_stop
|
||||
&& self.paint_region.is_none()
|
||||
&& iteration % self.options.node_batch_threshold == 0
|
||||
{
|
||||
self.soft_flush_gpu();
|
||||
}
|
||||
if allow_stop && self.should_stop_rendering(iteration, timestamp) {
|
||||
return Ok((is_empty, true));
|
||||
@ -4203,7 +4385,7 @@ impl RenderState {
|
||||
}
|
||||
|
||||
fn update_render_context_for_area(&mut self, area: Rect) {
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_paint_scale();
|
||||
self.render_area = area;
|
||||
let margins = self.surfaces.margins();
|
||||
let margin_w = margins.width as f32 / scale;
|
||||
@ -4219,6 +4401,9 @@ impl RenderState {
|
||||
|
||||
/// Drain pending uncached tiles into a paint-once region when safe.
|
||||
/// Returns true when `paint_region` was started and nodes were seeded.
|
||||
///
|
||||
/// Used for the soft settle pass (DPR≤1 fill-rate, one tree walk). The
|
||||
/// sharp HiDPI refill sets `sharp_tile_refill` and skips this path.
|
||||
fn try_begin_paint_region(
|
||||
&mut self,
|
||||
root_ids: &[Uuid],
|
||||
@ -4227,11 +4412,18 @@ impl RenderState {
|
||||
if self.viewer_masked_pass() || self.options.is_interactive_transform() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Sharp HiDPI pass: walk/paint per tile at full view DPR.
|
||||
if self.sharp_tile_refill {
|
||||
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() {
|
||||
let label = if self.options.content_quality() == ContentQuality::Interactive
|
||||
|| self.sharp_tile_refill
|
||||
|| self.pending_tiles.has_deferred_interest()
|
||||
{
|
||||
"visible"
|
||||
} else {
|
||||
"interest"
|
||||
@ -4255,10 +4447,11 @@ impl RenderState {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let scale = self.get_scale();
|
||||
let view_scale = self.get_scale();
|
||||
let paint_scale = self.get_paint_scale();
|
||||
let mut area = Rect::new_empty();
|
||||
for tile in ®ion_tiles {
|
||||
let r = tiles::get_tile_rect(*tile, scale);
|
||||
let r = tiles::get_tile_rect(*tile, view_scale, self.viewbox.dpr);
|
||||
if area.is_empty() {
|
||||
area = r;
|
||||
} else {
|
||||
@ -4266,8 +4459,12 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
if region_tiles.len() == 1 || !self.surfaces.region_fits_paint_surface(area, scale) {
|
||||
// Restore for the single-tile path (pop from end).
|
||||
// No banding: one paint-once of the whole pending set, or per-tile.
|
||||
if region_tiles.len() == 1
|
||||
|| !self
|
||||
.surfaces
|
||||
.region_fits_paint_surface(area, paint_scale)
|
||||
{
|
||||
self.pending_tiles.list.extend(region_tiles);
|
||||
return Ok(false);
|
||||
}
|
||||
@ -4311,7 +4508,7 @@ impl RenderState {
|
||||
|
||||
if self.zoom_perf_active {
|
||||
self.zoom_perf_log(&format!(
|
||||
"region={} START tiles={} shapes={} roots={} bg_blur={} skip_shadows={} zoom={:.3} scale={:.3} area=({:.1},{:.1},{:.1}x{:.1}) {}",
|
||||
"region={} START tiles={} shapes={} roots={} bg_blur={} skip_shadows={} zoom={:.3} paint_scale={:.3} view_scale={:.3} area=({:.1},{:.1},{:.1}x{:.1}) {}",
|
||||
label,
|
||||
region_tiles.len(),
|
||||
shape_ids.len(),
|
||||
@ -4319,7 +4516,8 @@ impl RenderState {
|
||||
region_has_bg_blur,
|
||||
self.should_skip_drop_shadows(),
|
||||
self.viewbox.zoom,
|
||||
self.get_scale(),
|
||||
paint_scale,
|
||||
view_scale,
|
||||
area.left,
|
||||
area.top,
|
||||
area.width(),
|
||||
@ -4350,15 +4548,16 @@ impl RenderState {
|
||||
crate::get_gpu_state().context.flush_and_submit();
|
||||
}
|
||||
self.cache_cleared_this_render = true;
|
||||
let scale = self.get_scale();
|
||||
let view_scale = self.get_scale();
|
||||
let paint_scale = self.get_paint_scale();
|
||||
let render_area = self.render_area;
|
||||
|
||||
for tile in ®ion.tiles {
|
||||
let tile_doc_rect = tiles::get_tile_rect(*tile, scale);
|
||||
let tile_doc_rect = tiles::get_tile_rect(*tile, view_scale, self.viewbox.dpr);
|
||||
let src = self.surfaces.tile_drawable_src_in_region(
|
||||
tile_doc_rect,
|
||||
render_area,
|
||||
scale,
|
||||
paint_scale,
|
||||
);
|
||||
let aligned = self.get_aligned_tile_bounds(*tile);
|
||||
self.surfaces.draw_current_src_into_tile_atlas(
|
||||
@ -4482,6 +4681,12 @@ impl RenderState {
|
||||
breakdown
|
||||
));
|
||||
}
|
||||
// Soft paint-once can be large; yield so the UI can run.
|
||||
if allow_stop {
|
||||
self.current_tile = None;
|
||||
self.viewer_render_root = None;
|
||||
return Ok(FrameType::Partial);
|
||||
}
|
||||
} else {
|
||||
let tile_rect = self.get_current_tile_bounds()?;
|
||||
// Composite if the walker did work in this PAF (`!is_empty`) OR
|
||||
@ -4529,6 +4734,17 @@ impl RenderState {
|
||||
breakdown
|
||||
));
|
||||
}
|
||||
// Sharp HiDPI: at most one tile per rAF. Also yield when
|
||||
// the frame budget is already spent.
|
||||
if allow_stop
|
||||
&& (self.sharp_tile_refill
|
||||
|| performance::get_time() - timestamp
|
||||
> self.render_time_budget_ms())
|
||||
{
|
||||
self.current_tile = None;
|
||||
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);
|
||||
@ -4680,7 +4896,7 @@ impl RenderState {
|
||||
pub fn get_tiles_for_shape(&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);
|
||||
let tile_size = tiles::get_tile_size(scale, self.viewbox.dpr);
|
||||
let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size);
|
||||
let interest_rect = &self.tile_viewbox.interest_rect;
|
||||
// Calculate the intersection of shape_tiles with interest_rect
|
||||
@ -5013,6 +5229,32 @@ impl RenderState {
|
||||
self.viewbox.get_scale()
|
||||
}
|
||||
|
||||
/// Doc→pixel scale for *tile raster* surfaces (Current/Fills/… in single-tile mode).
|
||||
///
|
||||
/// At full quality this equals [`Self::get_scale`] (`zoom * dpr`). During
|
||||
/// interactive HiDPI LOD, atlas sprites are only `BASE` px wide so the CTM
|
||||
/// must be `zoom` — otherwise shapes are drawn 2× too large into the tile.
|
||||
pub fn get_raster_scale(&self) -> f32 {
|
||||
if let Some((_, export_scale)) = self.export_context {
|
||||
return export_scale;
|
||||
}
|
||||
let raster = self.surfaces.tile_size_px() as f32;
|
||||
let world_tile =
|
||||
tiles::get_tile_size(self.viewbox.get_scale(), self.viewbox.dpr).max(1e-6);
|
||||
raster / world_tile
|
||||
}
|
||||
|
||||
/// Scale matching the active paint CTM on Current/Fills/….
|
||||
///
|
||||
/// Always [`Self::get_raster_scale`]: at full quality that equals view
|
||||
/// scale (`zoom * dpr`); during interactive HiDPI LOD it is `zoom` so
|
||||
/// paint-once and single-tile stamps both match DPR=1 fill-rate. Using
|
||||
/// view scale for paint-once during soft settle re-introduced the DPR=2
|
||||
/// zoom hitch (full-res walk, then another walk on sharp promote).
|
||||
pub fn get_paint_scale(&self) -> f32 {
|
||||
self.get_raster_scale()
|
||||
}
|
||||
|
||||
pub fn zoom_changed(&self) -> bool {
|
||||
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
|
||||
}
|
||||
|
||||
@ -99,7 +99,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
|
||||
paint.set_color(skia::Color::MAGENTA);
|
||||
paint.set_stroke_width(1.);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
let tile_size = tiles::get_tile_size(scale, render_state.viewbox.dpr);
|
||||
let tile_rect = tiles::get_tiles_for_rect(render_state.viewbox.area, tile_size);
|
||||
let tiles::TileRect(sx, sy, ex, ey) = tile_rect;
|
||||
|
||||
@ -108,7 +108,7 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) {
|
||||
let debug_font = get_resources().fonts.debug_font();
|
||||
canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
let tile_size = tiles::get_tile_size(scale, render_state.viewbox.dpr);
|
||||
for tile in tile_rect.iter(true) {
|
||||
let tiles::Tile(x, y) = tile;
|
||||
let rect = tile.get_rect_with_size(tile_size);
|
||||
|
||||
@ -4,15 +4,25 @@ const PROFILE_REBUILD_TILES: u32 = 0x02;
|
||||
const TEXT_EDITOR_V3: u32 = 0x04;
|
||||
const SHOW_WASM_INFO: u32 = 0x08;
|
||||
|
||||
// Render performance options
|
||||
// This is the extra area used for tile rendering (tiles beyond viewport).
|
||||
// Higher values pre-render more tiles, reducing empty squares during pan but using more memory.
|
||||
// Extra tiles beyond the viewport for pre-render. Kept in *tile* units (not
|
||||
// scaled by DPR): world tile count already matches DPR=1 (`BASE/zoom`).
|
||||
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1;
|
||||
const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2;
|
||||
const MAX_BLOCKING_TIME_MS: i32 = 32;
|
||||
const NODE_BATCH_THRESHOLD: i32 = 3;
|
||||
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
|
||||
const ANTIALIAS_THRESHOLD: f32 = 7.0;
|
||||
|
||||
/// Raster resolution for tile textures relative to the view DPR.
|
||||
///
|
||||
/// Interactive keeps raster tiles at 512 px (DPR=1 fill-rate) during pan/zoom;
|
||||
/// Full uses sharp `512 * dpr` tiles after settle.
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
|
||||
pub enum ContentQuality {
|
||||
Interactive,
|
||||
#[default]
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub struct RenderOptions {
|
||||
pub flags: u32,
|
||||
@ -23,6 +33,7 @@ pub struct RenderOptions {
|
||||
/// keeps per-frame flushing enabled (unlike pan/zoom, where
|
||||
/// `render_from_cache` drives target presentation).
|
||||
interactive_transform: bool,
|
||||
content_quality: ContentQuality,
|
||||
/// Minimum on-screen size (CSS px at 1:1 zoom) above which vector antialiasing is enabled.
|
||||
pub antialias_threshold: f32,
|
||||
pub viewport_interest_area_threshold: i32,
|
||||
@ -40,6 +51,7 @@ impl Default for RenderOptions {
|
||||
dpr: 1.0,
|
||||
fast_mode: false,
|
||||
interactive_transform: false,
|
||||
content_quality: ContentQuality::Full,
|
||||
antialias_threshold: ANTIALIAS_THRESHOLD,
|
||||
viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
dpr_viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD,
|
||||
@ -60,7 +72,6 @@ impl RenderOptions {
|
||||
self.flags & PROFILE_REBUILD_TILES == PROFILE_REBUILD_TILES
|
||||
}
|
||||
|
||||
/// Use fast mode to enable / disable expensive operations
|
||||
pub fn is_fast_mode(&self) -> bool {
|
||||
self.fast_mode
|
||||
}
|
||||
@ -69,19 +80,41 @@ impl RenderOptions {
|
||||
self.fast_mode = enabled;
|
||||
}
|
||||
|
||||
pub fn content_quality(&self) -> ContentQuality {
|
||||
self.content_quality
|
||||
}
|
||||
|
||||
/// Returns `true` when the quality value changed.
|
||||
pub fn set_content_quality(&mut self, quality: ContentQuality) -> bool {
|
||||
if self.content_quality != quality {
|
||||
self.content_quality = quality;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-pixel size used to rasterize each tile atlas sprite.
|
||||
pub fn raster_tile_size_px(&self) -> i32 {
|
||||
match self.content_quality {
|
||||
ContentQuality::Interactive if self.dpr > 1.05 => crate::tiles::TILE_SIZE_BASE as i32,
|
||||
_ => crate::tiles::tile_size_px_i32(self.dpr),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_full_quality_upgrade(&self) -> bool {
|
||||
self.content_quality == ContentQuality::Interactive && self.dpr > 1.05
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn set_capture_frames(&mut self, capture_frames: i32) {
|
||||
self.capture_frames = capture_frames;
|
||||
}
|
||||
|
||||
/// Updates the dpr viewport interest area threshold.
|
||||
/// This function is updated when the dpr or the
|
||||
/// viewport_interest_area_threshold is changed
|
||||
fn update_dpr_viewport_interest_area_threshold(&mut self) {
|
||||
// TODO: this will likely need to change once we have the tile atlas in place
|
||||
self.dpr_viewport_interest_area_threshold =
|
||||
((self.dpr * self.viewport_interest_area_threshold as f32).ceil() as i32)
|
||||
.min(MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD);
|
||||
// Interest stays in tile units; do not multiply by DPR (that ballooned
|
||||
// the cache/Current pad after DPR-stable world tiles).
|
||||
self.dpr_viewport_interest_area_threshold = self.viewport_interest_area_threshold;
|
||||
}
|
||||
|
||||
/// Sets the devicePixelRatio.
|
||||
@ -94,10 +127,6 @@ impl RenderOptions {
|
||||
false
|
||||
}
|
||||
|
||||
/// Interactive transform is ON while the user is dragging, resizing
|
||||
/// or rotating a shape. Callers use it to keep per-frame flushing
|
||||
/// enabled and to render visible tiles in a single frame so tiles
|
||||
/// never appear sequentially or flicker during the gesture.
|
||||
pub fn is_interactive_transform(&self) -> bool {
|
||||
self.interactive_transform
|
||||
}
|
||||
|
||||
@ -34,12 +34,16 @@ pub fn paint_surface_dims(
|
||||
viewport_w: i32,
|
||||
viewport_h: i32,
|
||||
interest_tiles: i32,
|
||||
tile_size_px: 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);
|
||||
let tile = tile_size_px.max(1);
|
||||
let margin = tile * TILE_SIZE_MULTIPLIER / 4;
|
||||
let pad = interest * tile + margin;
|
||||
let min_dim = tile + 2 * margin;
|
||||
let w = (viewport_w + 2 * pad).clamp(min_dim, max_texture_size);
|
||||
let h = (viewport_h + 2 * pad).clamp(min_dim, max_texture_size);
|
||||
skia::ISize::new(w, h)
|
||||
}
|
||||
|
||||
@ -66,7 +70,7 @@ fn draw_surface_src_rect_to_dst(
|
||||
to_canvas.restore();
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize {
|
||||
pub fn get_cache_size(viewbox: &Viewbox, interest: i32, tile_size_px: i32) -> skia::ISize {
|
||||
// First we retrieve the extended area of the viewport that we could render.
|
||||
let TileRect(isx, isy, iex, iey) =
|
||||
tiles::get_tiles_for_viewbox_with_interest(viewbox, interest);
|
||||
@ -75,8 +79,8 @@ pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize {
|
||||
let dy = if isy.signum() != iey.signum() { 1 } else { 0 };
|
||||
|
||||
(
|
||||
((iex - isx).abs() + dx) * TILE_SIZE,
|
||||
((iey - isy).abs() + dy) * TILE_SIZE,
|
||||
((iex - isx).abs() + dx) * tile_size_px,
|
||||
((iey - isy).abs() + dy) * tile_size_px,
|
||||
)
|
||||
.into()
|
||||
}
|
||||
@ -470,6 +474,8 @@ pub struct Surfaces {
|
||||
// Tracks which surfaces have content (dirty flag bitmask)
|
||||
dirty_surfaces: u32,
|
||||
extra_tile_dims: skia::ISize,
|
||||
/// Device-pixel size of one atlas tile sprite (512 interactive / 512*dpr full).
|
||||
tile_size_px: i32,
|
||||
dpr: f32,
|
||||
/// When true, accumulate draw_into wall time (ZOOM-PERF diagnosis).
|
||||
paint_diag: bool,
|
||||
@ -487,12 +493,14 @@ impl Surfaces {
|
||||
) -> Result<Self> {
|
||||
let gpu_state = get_gpu_state();
|
||||
let max_texture_size = gpu_state.max_texture_size();
|
||||
let tile_size_px = tile_dims.width.max(1);
|
||||
|
||||
// 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);
|
||||
paint_surface_dims(width, height, interest_tiles, tile_size_px, max_texture_size);
|
||||
let margin = tile_size_px * TILE_SIZE_MULTIPLIER / 4;
|
||||
let margins = skia::ISize::new(margin, margin);
|
||||
|
||||
let target = gpu_state.create_target_surface(width, height)?;
|
||||
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?;
|
||||
@ -525,7 +533,7 @@ impl Surfaces {
|
||||
let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?;
|
||||
|
||||
// 512, why not?
|
||||
let tiles = TileTextureCache::new(tile_atlas.width(), 512);
|
||||
let tiles = TileTextureCache::new(tile_atlas.width(), tile_size_px, 512);
|
||||
let atlas = DocAtlas::try_new()?;
|
||||
Ok(Self {
|
||||
target,
|
||||
@ -553,15 +561,81 @@ impl Surfaces {
|
||||
margins,
|
||||
dirty_surfaces: 0,
|
||||
extra_tile_dims,
|
||||
dpr: 1.0,
|
||||
tile_size_px,
|
||||
dpr: tile_size_px as f32 / tiles::TILE_SIZE_BASE,
|
||||
paint_diag: false,
|
||||
draw_into_ms: 0,
|
||||
draw_into_n: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_dpr(&mut self, dpr: f32) {
|
||||
pub fn tile_size_px(&self) -> i32 {
|
||||
self.tile_size_px
|
||||
}
|
||||
|
||||
fn tile_drawable_rect(&self) -> IRect {
|
||||
IRect {
|
||||
left: self.margins.width,
|
||||
top: self.margins.height,
|
||||
right: self.margins.width + self.tile_size_px,
|
||||
bottom: self.margins.height + self.tile_size_px,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_view_dpr(&mut self, dpr: f32) -> Result<()> {
|
||||
self.dpr = dpr;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update atlas sprite size for content-quality LOD. Does **not** shrink
|
||||
/// Current (paint-once keeps a viewport-sized paint surface).
|
||||
pub fn set_raster_tile_size(&mut self, tile_size_px: i32) -> Result<()> {
|
||||
let tile_size_px = tile_size_px.max(1);
|
||||
if tile_size_px == self.tile_size_px {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = performance::get_time();
|
||||
let old = self.tile_size_px;
|
||||
let margin = tile_size_px * TILE_SIZE_MULTIPLIER / 4;
|
||||
self.tile_size_px = tile_size_px;
|
||||
self.margins = skia::ISize::new(margin, margin);
|
||||
// Rebuild atlas packer for the new sprite size; keep Current as-is.
|
||||
self.tiles = TileTextureCache::new(self.tile_atlas.width(), tile_size_px, 512);
|
||||
self.tile_atlas_image = None;
|
||||
if self.paint_diag {
|
||||
println!(
|
||||
"[ZOOM-PERF] set_raster_tile_size {}→{} {}ms | {}",
|
||||
old,
|
||||
tile_size_px,
|
||||
performance::get_time() - t0,
|
||||
self.surfaces_size_summary()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_dpr(&mut self, dpr: f32) -> Result<()> {
|
||||
self.dpr = dpr;
|
||||
let new_tile_px = tiles::tile_size_px_i32(dpr);
|
||||
self.set_raster_tile_size(new_tile_px)
|
||||
}
|
||||
|
||||
pub fn invalidate_tile_cache(&mut self) {
|
||||
let t0 = performance::get_time();
|
||||
self.tiles.clear();
|
||||
self.tile_atlas_image = None;
|
||||
self.atlas.tile_doc_rects.clear();
|
||||
if self.paint_diag {
|
||||
println!(
|
||||
"[ZOOM-PERF] invalidate_tile_cache {}ms | {}",
|
||||
performance::get_time() - t0,
|
||||
self.surfaces_size_summary()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn was_rendered_without_atlas_slot(&self, tile: Tile) -> bool {
|
||||
self.tiles.was_rendered_without_slot(tile)
|
||||
}
|
||||
|
||||
pub fn clear_tiles(&mut self) {
|
||||
@ -574,14 +648,20 @@ impl Surfaces {
|
||||
tile_viewbox: &TileViewbox,
|
||||
background: skia::Color,
|
||||
) {
|
||||
let t0 = performance::get_time();
|
||||
self.tiles.update(viewbox, tile_viewbox);
|
||||
let t_upd = performance::get_time();
|
||||
let mut snap_ms = 0;
|
||||
if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() {
|
||||
let t_s = performance::get_time();
|
||||
self.tile_atlas_image = Some(self.tile_atlas.image_snapshot());
|
||||
self.tiles.snapshot();
|
||||
snap_ms = performance::get_time() - t_s;
|
||||
}
|
||||
let Some(atlas_image) = self.tile_atlas_image.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let t_draw = performance::get_time();
|
||||
let canvas = self.backbuffer.canvas();
|
||||
canvas.clear(background);
|
||||
canvas.draw_atlas(
|
||||
@ -594,6 +674,18 @@ impl Surfaces {
|
||||
None,
|
||||
None,
|
||||
);
|
||||
if self.paint_diag {
|
||||
println!(
|
||||
"[ZOOM-PERF] draw_tile_atlas_to_bb update={}ms snapshot={}ms draw={}ms total={}ms atlas={}x{} | {}",
|
||||
t_upd - t0,
|
||||
snap_ms,
|
||||
performance::get_time() - t_draw,
|
||||
performance::get_time() - t0,
|
||||
self.tile_atlas.width(),
|
||||
self.tile_atlas.height(),
|
||||
self.surfaces_size_summary()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the persistent atlas onto the backbuffer using the current viewbox transform.
|
||||
@ -711,12 +803,21 @@ impl Surfaces {
|
||||
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);
|
||||
let dims = paint_surface_dims(
|
||||
viewport_w,
|
||||
viewport_h,
|
||||
interest_tiles,
|
||||
self.tile_size_px,
|
||||
max_texture_size,
|
||||
);
|
||||
if dims == self.extra_tile_dims {
|
||||
return Ok(());
|
||||
}
|
||||
let t0 = performance::get_time();
|
||||
let old = self.extra_tile_dims;
|
||||
self.extra_tile_dims = dims;
|
||||
self.margins = skia::ISize::new(TILE_MARGIN_SIZE, TILE_MARGIN_SIZE);
|
||||
let margin = self.tile_size_px * TILE_SIZE_MULTIPLIER / 4;
|
||||
self.margins = skia::ISize::new(margin, margin);
|
||||
|
||||
let recreate = |name: &str, surface: &mut skia::Surface| -> Result<()> {
|
||||
*surface = surface
|
||||
@ -735,6 +836,17 @@ impl Surfaces {
|
||||
recreate("shape_fills", &mut self.shape_fills)?;
|
||||
recreate("shape_strokes", &mut self.shape_strokes)?;
|
||||
self.clear_all_dirty();
|
||||
if self.paint_diag {
|
||||
println!(
|
||||
"[ZOOM-PERF] resize_paint_surfaces {}x{}→{}x{} {}ms | {}",
|
||||
old.width,
|
||||
old.height,
|
||||
dims.width,
|
||||
dims.height,
|
||||
performance::get_time() - t0,
|
||||
self.surfaces_size_summary()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -750,6 +862,10 @@ impl Surfaces {
|
||||
TILE_DRAWABLE_RECT
|
||||
}
|
||||
|
||||
pub fn current_tile_drawable_rect(&self) -> IRect {
|
||||
self.tile_drawable_rect()
|
||||
}
|
||||
|
||||
pub fn snapshot(&mut self, id: SurfaceId) -> skia::Image {
|
||||
let surface = self.get_mut(id);
|
||||
surface.image_snapshot()
|
||||
@ -890,11 +1006,51 @@ impl Surfaces {
|
||||
|
||||
pub fn paint_diag_summary(&self) -> String {
|
||||
format!(
|
||||
"current={}x{} draw_into={}ms n={}",
|
||||
"current={}x{} draw_into={}ms n={} | {}",
|
||||
self.current.width(),
|
||||
self.current.height(),
|
||||
self.draw_into_ms,
|
||||
self.draw_into_n
|
||||
self.draw_into_n,
|
||||
self.surfaces_size_summary()
|
||||
)
|
||||
}
|
||||
|
||||
/// All GPU surface sizes relevant to HiDPI stalls (Current pad, atlas, cache).
|
||||
pub fn surfaces_size_summary(&self) -> String {
|
||||
let tile = self.tile_size_px.max(1);
|
||||
let atlas_side = self.tile_atlas.width();
|
||||
let slots = (atlas_side / tile) * (atlas_side / tile);
|
||||
format!(
|
||||
"tile_px={} margins={}x{} slots~{} dpr={:.2} current={}x{} fills={}x{} strokes={}x{} drop={}x{} inner={}x{} text_drop={}x{} filter={}x{} bb={}x{} target={}x{} cache={}x{} tile_atlas={}x{} extra={}x{}",
|
||||
self.tile_size_px,
|
||||
self.margins.width,
|
||||
self.margins.height,
|
||||
slots,
|
||||
self.dpr,
|
||||
self.current.width(),
|
||||
self.current.height(),
|
||||
self.shape_fills.width(),
|
||||
self.shape_fills.height(),
|
||||
self.shape_strokes.width(),
|
||||
self.shape_strokes.height(),
|
||||
self.drop_shadows.width(),
|
||||
self.drop_shadows.height(),
|
||||
self.inner_shadows.width(),
|
||||
self.inner_shadows.height(),
|
||||
self.text_drop_shadows.width(),
|
||||
self.text_drop_shadows.height(),
|
||||
self.filter.width(),
|
||||
self.filter.height(),
|
||||
self.backbuffer.width(),
|
||||
self.backbuffer.height(),
|
||||
self.target.width(),
|
||||
self.target.height(),
|
||||
self.cache.width(),
|
||||
self.cache.height(),
|
||||
self.tile_atlas.width(),
|
||||
self.tile_atlas.height(),
|
||||
self.extra_tile_dims.width,
|
||||
self.extra_tile_dims.height,
|
||||
)
|
||||
}
|
||||
|
||||
@ -1107,8 +1263,8 @@ impl Surfaces {
|
||||
.ok_or(Error::CriticalError("Failed to create surface".to_string()))?;
|
||||
self.cache.canvas().reset_matrix();
|
||||
self.cache.canvas().translate((
|
||||
(interest_area_threshold * TILE_SIZE) as f32,
|
||||
(interest_area_threshold * TILE_SIZE) as f32,
|
||||
(interest_area_threshold * self.tile_size_px) as f32,
|
||||
(interest_area_threshold * self.tile_size_px) as f32,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
@ -1116,17 +1272,36 @@ impl Surfaces {
|
||||
pub fn resize_cache_from_viewbox(
|
||||
&mut self,
|
||||
viewbox: &Viewbox,
|
||||
cached_viewbox: &Viewbox,
|
||||
_cached_viewbox: &Viewbox,
|
||||
interest_area_threshold: i32,
|
||||
) -> Result<()> {
|
||||
let viewbox_cache_size = get_cache_size(viewbox, interest_area_threshold);
|
||||
let cached_viewbox_cache_size = get_cache_size(cached_viewbox, interest_area_threshold);
|
||||
// Only resize cache if the new size is larger than the cached size
|
||||
// This avoids unnecessary surface recreations when the cache size decreases
|
||||
if viewbox_cache_size.width > cached_viewbox_cache_size.width
|
||||
|| viewbox_cache_size.height > cached_viewbox_cache_size.height
|
||||
{
|
||||
return self.resize_cache(viewbox_cache_size, interest_area_threshold);
|
||||
let needed = get_cache_size(viewbox, interest_area_threshold, self.tile_size_px);
|
||||
let current_w = self.cache.width();
|
||||
let current_h = self.cache.height();
|
||||
// Grow against the *actual* GPU surface. Comparing only to
|
||||
// `get_cache_size(cached_viewbox)` re-allocated the same (~100+ MiB)
|
||||
// cache on every progressive zoom/pan frame while `cached_viewbox`
|
||||
// still lagged behind a surface that had already grown.
|
||||
let new_w = needed.width.max(current_w);
|
||||
let new_h = needed.height.max(current_h);
|
||||
if new_w > current_w || new_h > current_h {
|
||||
let t0 = performance::get_time();
|
||||
let result = self.resize_cache(
|
||||
skia::ISize::new(new_w, new_h),
|
||||
interest_area_threshold,
|
||||
);
|
||||
if self.paint_diag {
|
||||
println!(
|
||||
"[ZOOM-PERF] resize_cache {}x{}→{}x{} {}ms | {}",
|
||||
current_w,
|
||||
current_h,
|
||||
new_w,
|
||||
new_h,
|
||||
performance::get_time() - t0,
|
||||
self.surfaces_size_summary()
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -1329,7 +1504,7 @@ impl Surfaces {
|
||||
tile_rect,
|
||||
skip_cache_surface,
|
||||
tile_doc_rect,
|
||||
skia::Rect::from(TILE_DRAWABLE_RECT),
|
||||
skia::Rect::from(self.tile_drawable_rect()),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1356,16 +1531,17 @@ impl Surfaces {
|
||||
);
|
||||
self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect);
|
||||
|
||||
let tile_ref = self.tiles.add(tile_viewbox, tile);
|
||||
let dst = tile_ref.rect;
|
||||
let mut current = self.current.clone();
|
||||
draw_surface_src_rect_to_dst(
|
||||
&mut current,
|
||||
self.tile_atlas.canvas(),
|
||||
src,
|
||||
dst,
|
||||
sampling,
|
||||
);
|
||||
if let Some(tile_ref) = self.tiles.add(tile_viewbox, tile) {
|
||||
let dst = tile_ref.rect;
|
||||
let mut current = self.current.clone();
|
||||
draw_surface_src_rect_to_dst(
|
||||
&mut current,
|
||||
self.tile_atlas.canvas(),
|
||||
src,
|
||||
dst,
|
||||
sampling,
|
||||
);
|
||||
}
|
||||
|
||||
if !skip_cache_surface {
|
||||
// Optional legacy Cache surface fill (debug). Pan/zoom preview
|
||||
@ -1399,7 +1575,10 @@ impl Surfaces {
|
||||
) -> 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)
|
||||
// At view scale this is `512 * dpr`; do not hardcode TILE_SIZE_BASE.
|
||||
let w = tile_doc_rect.width() * scale;
|
||||
let h = tile_doc_rect.height() * scale;
|
||||
skia::Rect::from_xywh(x, y, w, h)
|
||||
}
|
||||
|
||||
pub fn has_cached_tile_surface(&self, tile: Tile) -> bool {
|
||||
@ -1438,7 +1617,7 @@ impl Surfaces {
|
||||
let canvas = scratch.canvas();
|
||||
canvas.clear(skia::Color::TRANSPARENT);
|
||||
|
||||
let tile_size = tiles::get_tile_size(scale);
|
||||
let tile_size = tiles::get_tile_size(scale, self.dpr);
|
||||
let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size);
|
||||
let ix0 = src_irect.left as f32;
|
||||
let iy0 = src_irect.top as f32;
|
||||
@ -1447,7 +1626,7 @@ impl Surfaces {
|
||||
for ty in tr.y1()..=tr.y2() {
|
||||
for tx in tr.x1()..=tr.x2() {
|
||||
let tile = Tile(tx, ty);
|
||||
let tile_doc = tiles::get_tile_rect(tile, scale);
|
||||
let tile_doc = tiles::get_tile_rect(tile, scale, self.dpr);
|
||||
let mut clip_doc = tile_doc;
|
||||
if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() {
|
||||
continue;
|
||||
@ -1578,12 +1757,6 @@ impl Surfaces {
|
||||
/// This forces all tiles to be re-rendered, but preserves the cache canvas
|
||||
/// so that `render_from_cache` can still show a scaled preview of the old
|
||||
/// content while new tiles are being rendered.
|
||||
pub fn invalidate_tile_cache(&mut self) {
|
||||
self.tiles.clear();
|
||||
self.atlas.tile_doc_rects.clear();
|
||||
self.tile_atlas_image = None;
|
||||
}
|
||||
|
||||
pub fn gc(&mut self) {
|
||||
self.tiles.gc();
|
||||
}
|
||||
@ -1713,10 +1886,13 @@ pub struct TileTextureCache {
|
||||
tile_size: f32,
|
||||
is_updated: bool,
|
||||
provider: TileAtlasTextureProvider,
|
||||
transforms: Vec<skia::RSXform>,
|
||||
textures: Vec<skia::Rect>,
|
||||
pub transforms: Vec<skia::RSXform>,
|
||||
pub textures: Vec<skia::Rect>,
|
||||
grid: HashMap<Tile, TileAtlasTextureRef>,
|
||||
removed: HashSet<Tile>,
|
||||
/// Interest tiles drawn without an atlas slot. Pending scheduler skips them;
|
||||
/// `has()` stays false until they get a real sprite.
|
||||
rendered_without_slot: HashSet<Tile>,
|
||||
}
|
||||
|
||||
pub struct AtlasDrawBatch {
|
||||
@ -1731,24 +1907,26 @@ impl AtlasDrawBatch {
|
||||
}
|
||||
|
||||
impl TileTextureCache {
|
||||
pub fn new(texture_size: i32, capacity: usize) -> Self {
|
||||
pub fn new(texture_size: i32, tile_size_px: i32, capacity: usize) -> Self {
|
||||
Self {
|
||||
tile_size: tiles::TILE_SIZE,
|
||||
tile_size: tile_size_px as f32,
|
||||
is_updated: false,
|
||||
provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE),
|
||||
provider: TileAtlasTextureProvider::new(texture_size, tile_size_px),
|
||||
transforms: Vec::with_capacity(capacity),
|
||||
textures: Vec::with_capacity(capacity),
|
||||
grid: HashMap::with_capacity(capacity),
|
||||
removed: HashSet::with_capacity(capacity),
|
||||
rendered_without_slot: HashSet::with_capacity(capacity),
|
||||
}
|
||||
}
|
||||
|
||||
fn gc(&mut self) {
|
||||
// Make a real remove
|
||||
for tile in self.removed.iter() {
|
||||
let removed: Vec<Tile> = self.removed.iter().copied().collect();
|
||||
for tile in removed.iter() {
|
||||
if let Some(tile_ref) = self.grid.remove(tile) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
self.removed.remove(tile);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1761,9 +1939,13 @@ impl TileTextureCache {
|
||||
}
|
||||
|
||||
fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) {
|
||||
self.gc_non_visible_limited(tile_viewbox, TEXTURES_BATCH_DELETE);
|
||||
}
|
||||
|
||||
fn gc_non_visible_limited(&mut self, tile_viewbox: &TileViewbox, limit: usize) {
|
||||
let marked: Vec<_> = self
|
||||
.grid
|
||||
.iter_mut()
|
||||
.iter()
|
||||
.filter_map(|(tile, _)| {
|
||||
if !tile_viewbox.is_visible(tile) {
|
||||
Some(*tile)
|
||||
@ -1771,13 +1953,14 @@ impl TileTextureCache {
|
||||
None
|
||||
}
|
||||
})
|
||||
.take(TEXTURES_BATCH_DELETE)
|
||||
.take(limit)
|
||||
.collect();
|
||||
|
||||
for tile in marked.iter() {
|
||||
if let Some(tile_ref) = self.grid.remove(tile) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
self.removed.remove(tile);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1801,6 +1984,12 @@ impl TileTextureCache {
|
||||
}
|
||||
|
||||
let offset = viewbox.get_offset();
|
||||
let device_tile = tiles::device_tile_size_px(viewbox.dpr);
|
||||
let scale = if self.tile_size > 0.0 {
|
||||
device_tile / self.tile_size
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let mut index = 0;
|
||||
for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() {
|
||||
for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() {
|
||||
@ -1814,8 +2003,14 @@ impl TileTextureCache {
|
||||
continue;
|
||||
}
|
||||
|
||||
self.transforms[index].tx = x as f32 * self.tile_size - offset.x;
|
||||
self.transforms[index].ty = y as f32 * self.tile_size - offset.y;
|
||||
self.transforms[index] = skia::RSXform::new(
|
||||
scale,
|
||||
0.0,
|
||||
(
|
||||
x as f32 * device_tile - offset.x,
|
||||
y as f32 * device_tile - offset.y,
|
||||
),
|
||||
);
|
||||
|
||||
self.textures[index].set_ltrb(
|
||||
tile_ref.rect.left,
|
||||
@ -1856,7 +2051,7 @@ impl TileTextureCache {
|
||||
let doc_rect = tile_doc_rects
|
||||
.get(&tile)
|
||||
.copied()
|
||||
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
|
||||
.unwrap_or_else(|| tiles::get_tile_rect(tile, s, viewbox.dpr));
|
||||
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
|
||||
continue;
|
||||
}
|
||||
@ -1870,8 +2065,6 @@ impl TileTextureCache {
|
||||
}
|
||||
}
|
||||
|
||||
// Cached tiles from a previous zoom level use indices outside visible_rect;
|
||||
// place them via their stored document rect, not the current grid walk above.
|
||||
for (&tile, tile_ref) in &self.grid {
|
||||
if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) {
|
||||
continue;
|
||||
@ -1880,7 +2073,7 @@ impl TileTextureCache {
|
||||
let doc_rect = tile_doc_rects
|
||||
.get(&tile)
|
||||
.copied()
|
||||
.unwrap_or_else(|| tiles::get_tile_rect(tile, s));
|
||||
.unwrap_or_else(|| tiles::get_tile_rect(tile, s, viewbox.dpr));
|
||||
if doc_rect.is_empty() || !doc_rect.intersects(view_doc) {
|
||||
continue;
|
||||
}
|
||||
@ -1903,10 +2096,22 @@ impl TileTextureCache {
|
||||
self.grid.contains_key(&tile) && !self.removed.contains(&tile)
|
||||
}
|
||||
|
||||
pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef {
|
||||
// Evict against the real slot count (`provider.length`), not the
|
||||
// hardcoded capacity — otherwise the guard never fires and the atlas
|
||||
// fills up until `allocate()` has no slot left.
|
||||
pub fn was_rendered_without_slot(&self, tile: Tile) -> bool {
|
||||
self.rendered_without_slot.contains(&tile)
|
||||
}
|
||||
|
||||
pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> Option<TileAtlasTextureRef> {
|
||||
if let Some(existing) = self.grid.get(tile).cloned() {
|
||||
if !self.removed.contains(tile) {
|
||||
self.rendered_without_slot.remove(tile);
|
||||
return Some(existing);
|
||||
}
|
||||
if let Some(tile_ref) = self.grid.remove(tile) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
self.removed.remove(tile);
|
||||
}
|
||||
|
||||
let capacity = self.provider.length.min(TEXTURES_CACHE_CAPACITY);
|
||||
|
||||
if self.grid.len() >= capacity {
|
||||
@ -1914,18 +2119,58 @@ impl TileTextureCache {
|
||||
self.gc_non_visible(tile_viewbox);
|
||||
}
|
||||
|
||||
let Some(tile_ref) = self.provider.allocate() else {
|
||||
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(tile_ref) = self.provider.allocate() {
|
||||
self.grid.insert(*tile, tile_ref.clone());
|
||||
self.rendered_without_slot.remove(tile);
|
||||
self.is_updated = true;
|
||||
return Some(tile_ref);
|
||||
}
|
||||
|
||||
self.is_updated = true;
|
||||
tile_ref.clone()
|
||||
self.gc();
|
||||
self.gc_non_visible_limited(tile_viewbox, usize::MAX);
|
||||
if let Some(tile_ref) = self.provider.allocate() {
|
||||
self.grid.insert(*tile, tile_ref.clone());
|
||||
self.rendered_without_slot.remove(tile);
|
||||
self.is_updated = true;
|
||||
return Some(tile_ref);
|
||||
}
|
||||
|
||||
if tile_viewbox.is_visible(tile) {
|
||||
if let Some(victim) = self.farthest_visible_victim(tile_viewbox, tile) {
|
||||
if let Some(tile_ref) = self.grid.remove(&victim) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
self.removed.remove(&victim);
|
||||
self.rendered_without_slot.insert(victim);
|
||||
}
|
||||
if let Some(tile_ref) = self.provider.allocate() {
|
||||
self.grid.insert(*tile, tile_ref.clone());
|
||||
self.rendered_without_slot.remove(tile);
|
||||
self.is_updated = true;
|
||||
return Some(tile_ref);
|
||||
}
|
||||
}
|
||||
|
||||
self.rendered_without_slot.insert(*tile);
|
||||
None
|
||||
}
|
||||
|
||||
fn farthest_visible_victim(
|
||||
&self,
|
||||
tile_viewbox: &TileViewbox,
|
||||
keep: &Tile,
|
||||
) -> Option<Tile> {
|
||||
let cx = (tile_viewbox.visible_rect.left() + tile_viewbox.visible_rect.right()) / 2;
|
||||
let cy = (tile_viewbox.visible_rect.top() + tile_viewbox.visible_rect.bottom()) / 2;
|
||||
self.grid
|
||||
.keys()
|
||||
.filter(|t| *t != keep && tile_viewbox.is_visible(t) && !self.removed.contains(t))
|
||||
.max_by_key(|t| {
|
||||
let dx = t.0 - cx;
|
||||
let dy = t.1 - cy;
|
||||
dx * dx + dy * dy
|
||||
})
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> {
|
||||
@ -1943,12 +2188,19 @@ impl TileTextureCache {
|
||||
}
|
||||
self.is_updated = true;
|
||||
self.removed.insert(tile);
|
||||
self.rendered_without_slot.remove(&tile);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
for k in self.grid.keys() {
|
||||
self.removed.insert(*k);
|
||||
// Free GPU atlas slots immediately so DPR-scaled (few) slots are not stuck.
|
||||
let keys: Vec<Tile> = self.grid.keys().copied().collect();
|
||||
for tile in keys {
|
||||
if let Some(tile_ref) = self.grid.remove(&tile) {
|
||||
self.provider.deallocate(tile_ref);
|
||||
}
|
||||
}
|
||||
self.removed.clear();
|
||||
self.rendered_without_slot.clear();
|
||||
self.is_updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,12 +32,12 @@ impl Tile {
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn get_rect_with_offset(&self, offset: &skia::Point) -> skia::Rect {
|
||||
pub fn get_rect_with_offset(&self, offset: &skia::Point, tile_size_px: f32) -> skia::Rect {
|
||||
skia::Rect::from_xywh(
|
||||
self.0 as f32 * TILE_SIZE - offset.x,
|
||||
self.1 as f32 * TILE_SIZE - offset.y,
|
||||
TILE_SIZE,
|
||||
TILE_SIZE,
|
||||
self.0 as f32 * tile_size_px - offset.x,
|
||||
self.1 as f32 * tile_size_px - offset.y,
|
||||
tile_size_px,
|
||||
tile_size_px,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -209,11 +209,40 @@ impl TileViewbox {
|
||||
}
|
||||
}
|
||||
|
||||
pub const TILE_SIZE: f32 = 512.;
|
||||
/// Base tile texture size at DPR=1 (device pixels).
|
||||
pub const TILE_SIZE_BASE: f32 = 512.;
|
||||
|
||||
/// Alias for [`TILE_SIZE_BASE`]. Prefer [`device_tile_size_px`] for layout size.
|
||||
pub const TILE_SIZE: f32 = TILE_SIZE_BASE;
|
||||
|
||||
/// Device-pixel coverage of one tile on the backbuffer at the given view DPR.
|
||||
/// At DPR=2 this is 1024 — used for placing sprites, not necessarily for
|
||||
/// rasterizing them (see content-quality / interactive LOD).
|
||||
#[inline(always)]
|
||||
pub fn device_tile_size_px(dpr: f32) -> f32 {
|
||||
(TILE_SIZE_BASE * dpr.max(0.01)).round().max(1.0)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn get_tile_dimensions() -> skia::ISize {
|
||||
(TILE_SIZE as i32, TILE_SIZE as i32).into()
|
||||
pub fn device_tile_size_px_i32(dpr: f32) -> i32 {
|
||||
device_tile_size_px(dpr) as i32
|
||||
}
|
||||
|
||||
/// Alias kept for call sites that mean "full-quality raster size == device coverage".
|
||||
#[inline(always)]
|
||||
pub fn tile_size_px(dpr: f32) -> f32 {
|
||||
device_tile_size_px(dpr)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn tile_size_px_i32(dpr: f32) -> i32 {
|
||||
device_tile_size_px_i32(dpr)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn get_tile_dimensions(dpr: f32) -> skia::ISize {
|
||||
let s = device_tile_size_px_i32(dpr);
|
||||
(s, s).into()
|
||||
}
|
||||
|
||||
pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect {
|
||||
@ -227,7 +256,7 @@ pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect {
|
||||
}
|
||||
|
||||
pub fn get_tiles_for_viewbox(viewbox: &Viewbox) -> TileRect {
|
||||
let tile_size = get_tile_size(viewbox.get_scale());
|
||||
let tile_size = get_tile_size(viewbox.get_scale(), viewbox.dpr);
|
||||
get_tiles_for_rect(viewbox.area, tile_size)
|
||||
}
|
||||
|
||||
@ -241,20 +270,22 @@ pub fn get_tile_center_for_viewbox(viewbox: &Viewbox) -> Tile {
|
||||
Tile((ex - sx) / 2, (ey - sy) / 2)
|
||||
}
|
||||
|
||||
pub fn get_tile_pos(Tile(x, y): Tile, scale: f32) -> (f32, f32) {
|
||||
(
|
||||
x as f32 * get_tile_size(scale),
|
||||
y as f32 * get_tile_size(scale),
|
||||
)
|
||||
pub fn get_tile_pos(Tile(x, y): Tile, scale: f32, dpr: f32) -> (f32, f32) {
|
||||
let ts = get_tile_size(scale, dpr);
|
||||
(x as f32 * ts, y as f32 * ts)
|
||||
}
|
||||
|
||||
pub fn get_tile_size(scale: f32) -> f32 {
|
||||
1. / scale * TILE_SIZE
|
||||
/// World/document size of one tile at `scale = zoom * dpr`.
|
||||
/// Always `BASE / zoom` so viewport tile *count* matches DPR=1 regardless of
|
||||
/// the raster tile texture size used for LOD.
|
||||
pub fn get_tile_size(scale: f32, dpr: f32) -> f32 {
|
||||
let zoom = (scale / dpr.max(0.01)).max(1e-6);
|
||||
TILE_SIZE_BASE / zoom
|
||||
}
|
||||
|
||||
pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect {
|
||||
let (tx, ty) = get_tile_pos(tile, scale);
|
||||
let ts = get_tile_size(scale);
|
||||
pub fn get_tile_rect(tile: Tile, scale: f32, dpr: f32) -> skia::Rect {
|
||||
let (tx, ty) = get_tile_pos(tile, scale, dpr);
|
||||
let ts = get_tile_size(scale, dpr);
|
||||
skia::Rect::from_xywh(tx, ty, ts, ts)
|
||||
}
|
||||
|
||||
@ -345,11 +376,9 @@ impl PendingTiles {
|
||||
self.list.clear();
|
||||
self.deferred_interest.clear();
|
||||
|
||||
// During interactive transform, skip the interest-area ring
|
||||
// entirely: the user is dragging, every rAF is on the critical
|
||||
// path, and pre-rendering tiles outside the viewport is wasted
|
||||
// work that just gets evicted on the next pointer move. The ring
|
||||
// is repopulated naturally on gesture end / on idle rAFs.
|
||||
// During interactive transform / soft HiDPI settle, skip the interest
|
||||
// ring — every rAF is on the critical path. The ring is repopulated on
|
||||
// gesture end or the full-quality upgrade pass.
|
||||
let tile_rect = if only_visible {
|
||||
&tile_viewbox.visible_rect
|
||||
} else {
|
||||
@ -388,7 +417,11 @@ impl PendingTiles {
|
||||
for (_, tile) in self.tile_order.iter() {
|
||||
let tile = *tile;
|
||||
let is_visible = tile_viewbox.visible_rect.contains(&tile);
|
||||
let is_cached = surfaces.has_cached_tile_surface(tile);
|
||||
// Interest tiles that were drawn but could not get an atlas slot must
|
||||
// not be re-queued (would loop forever with DPR-scaled 16-slot atlases).
|
||||
// Once they become visible, `has` is false until they get a real slot.
|
||||
let is_cached = surfaces.has_cached_tile_surface(tile)
|
||||
|| (!is_visible && surfaces.was_rendered_without_atlas_slot(tile));
|
||||
|
||||
match (is_visible, is_cached) {
|
||||
(true, true) => self.visible_cached.push(tile),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user