This commit is contained in:
Alejandro Alonso 2026-08-04 10:57:42 +02:00
parent 379eb9b343
commit 5f921ecb9e
7 changed files with 781 additions and 208 deletions

View File

@ -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 (~216MiB).
- 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.

View File

@ -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

View File

@ -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 &region_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 &region.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
}

View File

@ -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);

View File

@ -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
}

View File

@ -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;
}
}

View File

@ -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),