mirror of
https://github.com/penpot/penpot.git
synced 2026-08-07 05:18:36 +00:00
⚡ Soft-then-sharp HiDPI zoom settle without pan freezes
Keep interest in tile units (not × DPR), always yield with allow_stop, cap drag crops to the backbuffer, and refill zoom at 512 px before a second full-DPR pass so HiDPI feels like DPR=1.
This commit is contained in:
parent
4b9c94c0da
commit
ac203569c9
@ -339,6 +339,8 @@ pub extern "C" fn set_view_start() -> Result<()> {
|
||||
VIEW_INTERACTION_START = performance::get_time();
|
||||
}
|
||||
performance::begin_measure!("set_view_start");
|
||||
// Keep existing tile textures for `render_from_cache` during the gesture.
|
||||
// Quality LOD is applied on `set_view_end` when a zoom forces a refill.
|
||||
get_render_state().options.set_fast_mode(true);
|
||||
performance::end_measure!("set_view_start");
|
||||
Ok(())
|
||||
@ -366,6 +368,10 @@ 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.
|
||||
render_state.enter_interactive_content_quality()?;
|
||||
render_state.rebuild_tile_index(&state.shapes);
|
||||
render_state.surfaces.invalidate_tile_cache();
|
||||
} else {
|
||||
|
||||
@ -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};
|
||||
@ -45,6 +45,7 @@ pub(crate) use resources::RenderResources;
|
||||
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
|
||||
|
||||
#[repr(u8)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum FrameType {
|
||||
None = 0,
|
||||
Partial = 1,
|
||||
@ -427,13 +428,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,
|
||||
@ -444,12 +450,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);
|
||||
@ -551,7 +558,7 @@ impl RenderState {
|
||||
let surfaces = Surfaces::try_new(
|
||||
(width, height),
|
||||
sampling_options,
|
||||
tiles::get_tile_dimensions(),
|
||||
tiles::get_tile_dimensions(1.0),
|
||||
)?;
|
||||
|
||||
Self::assemble(width, height, surfaces)
|
||||
@ -702,7 +709,7 @@ impl RenderState {
|
||||
None => return,
|
||||
};
|
||||
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
let scaled_sigma = radius_to_sigma(blur.value * scale);
|
||||
// Cap sigma so the blur kernel (≈3σ) stays within the tile margin.
|
||||
// This prevents visible seams at tile boundaries when zoomed in.
|
||||
@ -873,17 +880,73 @@ 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);
|
||||
// Recreate tile-sized surfaces for the current content quality
|
||||
// (interactive = 512 px, full = 512*dpr) before resizing targets.
|
||||
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);
|
||||
// Tile grid world size is BASE/zoom (stable across DPR); still
|
||||
// drop the shape→tile index so the next render rebuilds it.
|
||||
self.tiles.invalidate();
|
||||
self.tile_viewbox.update(&self.viewbox);
|
||||
}
|
||||
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(());
|
||||
}
|
||||
self.surfaces
|
||||
.set_raster_tile_size(self.options.raster_tile_size_px())?;
|
||||
self.surfaces.invalidate_tile_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Promote to full-DPR tile textures after an interactive settle. Returns
|
||||
/// `true` when a new progressive pass is required.
|
||||
pub fn try_begin_full_quality_pass(&mut self, _tree: ShapesPoolRef) -> Result<bool> {
|
||||
if !self.options.needs_full_quality_upgrade() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.options.set_content_quality(ContentQuality::Full);
|
||||
self.surfaces
|
||||
.set_raster_tile_size(self.options.raster_tile_size_px())?;
|
||||
self.surfaces.invalidate_tile_cache();
|
||||
// Fresh tile surfaces need the tile-raster CTM (see get_raster_scale).
|
||||
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);
|
||||
self.pending_tiles
|
||||
.update(&self.tile_viewbox, &self.surfaces, false);
|
||||
self.current_tile = None;
|
||||
self.pending_nodes.clear();
|
||||
self.cache_cleared_this_render = false;
|
||||
self.preserve_target_during_render = true;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_antialias_threshold(&mut self, value: f32) {
|
||||
self.options.set_antialias_threshold(value);
|
||||
}
|
||||
@ -1055,7 +1118,7 @@ impl RenderState {
|
||||
// (snapshot -> atlas blit -> tiles.add) can force GPU stalls. Defer cache rebuild until
|
||||
// the interaction ends.
|
||||
if self.options.is_interactive_transform() {
|
||||
let tile_rect = self.get_current_aligned_tile_bounds()?;
|
||||
let tile_rect = self.get_current_device_aligned_tile_bounds()?;
|
||||
self.surfaces.draw_current_tile_into_backbuffer(
|
||||
&tile_rect,
|
||||
self.background_color,
|
||||
@ -1319,7 +1382,7 @@ impl RenderState {
|
||||
&& target_surface != SurfaceId::Export;
|
||||
|
||||
if can_render_directly {
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
let translation = self
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
@ -1362,7 +1425,7 @@ impl RenderState {
|
||||
|
||||
// set clipping
|
||||
if let Some(clips) = clip_bounds.as_ref() {
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
for (mut bounds, corners, transform) in clips.iter() {
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().concat(transform);
|
||||
@ -1513,7 +1576,7 @@ impl RenderState {
|
||||
rebound_text_content.as_ref().unwrap_or(stored_text_content);
|
||||
let count_inner_strokes = shape.count_visible_inner_strokes();
|
||||
// Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge.
|
||||
let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale());
|
||||
let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_raster_scale());
|
||||
let text_stroke_blur_outset =
|
||||
Stroke::max_bounds_width(shape.visible_strokes(), false);
|
||||
let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None);
|
||||
@ -1915,18 +1978,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) {
|
||||
@ -2005,19 +2070,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 {
|
||||
@ -2040,7 +2108,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,
|
||||
@ -2080,6 +2149,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.
|
||||
@ -2137,7 +2209,6 @@ impl RenderState {
|
||||
self.background_color,
|
||||
);
|
||||
self.present_frame(shapes);
|
||||
|
||||
performance::end_measure!("render_from_cache");
|
||||
performance::end_timed_log!("render_from_cache", _start);
|
||||
}
|
||||
@ -2202,7 +2273,7 @@ impl RenderState {
|
||||
self.clear(tree);
|
||||
|
||||
let _start = performance::begin_timed_log!("start_render_loop");
|
||||
let scale = self.get_scale();
|
||||
let raster_scale = self.get_raster_scale();
|
||||
|
||||
self.tile_viewbox.update(&self.viewbox);
|
||||
self.focus_mode.reset();
|
||||
@ -2261,7 +2332,7 @@ impl RenderState {
|
||||
| SurfaceId::TextDropShadows as u32;
|
||||
|
||||
self.surfaces.apply_mut(surface_ids, |s| {
|
||||
s.canvas().scale((scale, scale));
|
||||
s.canvas().scale((raster_scale, raster_scale));
|
||||
});
|
||||
|
||||
self.surfaces.resize_cache_from_viewbox(
|
||||
@ -2276,7 +2347,8 @@ impl RenderState {
|
||||
let _tile_start = performance::begin_timed_log!("tile_cache_update");
|
||||
|
||||
performance::begin_measure!("tile_cache");
|
||||
let only_visible = self.options.is_interactive_transform();
|
||||
let only_visible = self.options.is_interactive_transform()
|
||||
|| self.options.content_quality() == ContentQuality::Interactive;
|
||||
self.pending_tiles
|
||||
.update(&self.tile_viewbox, &self.surfaces, only_visible);
|
||||
performance::end_measure!("tile_cache");
|
||||
@ -2290,10 +2362,16 @@ 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
|
||||
// presented frame visible while tiles fill in (pan-end, zoom-end, and
|
||||
// localized shape edits via `rebuild_touched_tiles`).
|
||||
//
|
||||
// Previously `allow_stop` was false when `preserve_target && !zoom_changed`
|
||||
// (intended to finish localized recolors in one frame). After pan that
|
||||
// forced a synchronous pass over the whole interest area and blocked the
|
||||
// browser for 1–2s at HiDPI deep zoom (DPR=2). Localized edits still
|
||||
// finish in one frame when little work remains within max_blocking_time.
|
||||
let allow_stop = true;
|
||||
frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?;
|
||||
|
||||
// This is an option to debug frames.
|
||||
@ -2312,7 +2390,6 @@ impl RenderState {
|
||||
self.cached_viewbox = self.viewbox;
|
||||
}
|
||||
}
|
||||
|
||||
performance::end_measure!("start_render_loop");
|
||||
performance::end_timed_log!("start_render_loop", _start);
|
||||
Ok(frame_type)
|
||||
@ -2364,11 +2441,25 @@ impl RenderState {
|
||||
// presented (only flushed), so defer composition to the final frame and
|
||||
// avoid re-snapshotting up to 4096² on every rAF during async tile work.
|
||||
if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) {
|
||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
||||
// Doc-scaled placement handles interactive LOD (512 → device) correctly.
|
||||
self.surfaces.draw_combined_atlas_to_backbuffer(
|
||||
&self.viewbox,
|
||||
&self.tile_viewbox,
|
||||
self.background_color,
|
||||
);
|
||||
|
||||
// HiDPI settle: show the fast 512 px pass, then promote to sharp
|
||||
// 512*dpr tiles without notifying "complete" yet.
|
||||
if self.options.needs_full_quality_upgrade() {
|
||||
if !self.options.is_fast_mode() {
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
self.present_frame(tree);
|
||||
self.try_begin_full_quality_pass(tree)?;
|
||||
self.flush();
|
||||
performance::end_measure!("continue_render_loop");
|
||||
return Ok(FrameType::Partial);
|
||||
}
|
||||
}
|
||||
|
||||
match frame_type {
|
||||
@ -2527,10 +2618,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_raster_scale();
|
||||
self.surfaces
|
||||
.update_render_context(self.render_area, workspace_scale);
|
||||
}
|
||||
@ -2626,10 +2717,10 @@ impl RenderState {
|
||||
if mask_group_blur {
|
||||
self.surfaces.canvas(target_surface).save();
|
||||
if let Some(clips) = clip_bounds {
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
let antialias = !self.options.is_fast_mode()
|
||||
&& element
|
||||
.should_use_antialias(scale, self.options.antialias_threshold);
|
||||
.should_use_antialias(self.get_scale(), self.options.antialias_threshold);
|
||||
self.clip_target_surface_to_stack(clips, target_surface, scale, antialias);
|
||||
}
|
||||
}
|
||||
@ -2637,7 +2728,7 @@ impl RenderState {
|
||||
let mut paint = skia::Paint::default();
|
||||
if !self.options.is_fast_mode() {
|
||||
if let Some(blur) = element.masked_group_layer_blur() {
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
let sigma = radius_to_sigma(blur.value * scale);
|
||||
if let Some(filter) =
|
||||
skia::image_filters::blur((sigma, sigma), None, None, None)
|
||||
@ -2814,7 +2905,9 @@ 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))
|
||||
// Backbuffer placement uses device coverage (may be larger than raster).
|
||||
let tile_px = tiles::device_tile_size_px(self.viewbox.dpr);
|
||||
Ok(tile.get_rect_with_offset(&offset, tile_px))
|
||||
}
|
||||
|
||||
pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect {
|
||||
@ -2840,28 +2933,42 @@ impl RenderState {
|
||||
self.get_rect_bounds(rect)
|
||||
}
|
||||
|
||||
/// Tile bounds in *raster* / cache surface coordinates (`surfaces.tile_size_px`).
|
||||
/// Grid indices follow world tiling (`BASE/zoom`); raster cells may be smaller
|
||||
/// than device coverage when interactive LOD is active.
|
||||
pub fn get_aligned_tile_bounds(&mut self, tile: tiles::Tile) -> Rect {
|
||||
let scale = self.get_scale();
|
||||
let start_tile_x =
|
||||
(self.viewbox.area.left * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE;
|
||||
let start_tile_y =
|
||||
(self.viewbox.area.top * scale / tiles::TILE_SIZE).floor() * tiles::TILE_SIZE;
|
||||
let raster_px = self.surfaces.tile_size_px() as f32;
|
||||
let device_px = tiles::device_tile_size_px(self.viewbox.dpr);
|
||||
// Origin tile index from device/world grid (stable across LOD).
|
||||
let start_idx_x = (self.viewbox.area.left * scale / device_px).floor();
|
||||
let start_idx_y = (self.viewbox.area.top * scale / device_px).floor();
|
||||
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 - start_idx_x) * raster_px,
|
||||
(tile.y() as f32 - start_idx_y) * raster_px,
|
||||
raster_px,
|
||||
raster_px,
|
||||
)
|
||||
}
|
||||
|
||||
/// Tile bounds on the backbuffer using device coverage (`BASE * view_dpr`).
|
||||
pub fn get_device_aligned_tile_bounds(&mut self, tile: tiles::Tile) -> Rect {
|
||||
let scale = self.get_scale();
|
||||
let tile_px = tiles::device_tile_size_px(self.viewbox.dpr);
|
||||
let start_tile_x =
|
||||
(self.viewbox.area.left * scale / tile_px).floor() * tile_px;
|
||||
let start_tile_y =
|
||||
(self.viewbox.area.top * scale / tile_px).floor() * tile_px;
|
||||
Rect::from_xywh(
|
||||
(tile.x() as f32 * tile_px) - start_tile_x,
|
||||
(tile.y() as f32 * tile_px) - start_tile_y,
|
||||
tile_px,
|
||||
tile_px,
|
||||
)
|
||||
}
|
||||
|
||||
// Returns the bounds of the current tile relative to the viewbox,
|
||||
// aligned to the nearest tile grid origin.
|
||||
//
|
||||
// Unlike `get_current_tile_bounds`, which calculates bounds using the exact
|
||||
// scaled offset of the viewbox, this method snaps the origin to the nearest
|
||||
// lower multiple of `TILE_SIZE`. This ensures the tile bounds are aligned
|
||||
// with the global tile grid, which is useful for rendering tiles in a
|
||||
/// consistent and predictable layout.
|
||||
// aligned to the nearest tile grid origin (raster / cache coordinates).
|
||||
pub fn get_current_aligned_tile_bounds(&mut self) -> Result<Rect> {
|
||||
Ok(self.get_aligned_tile_bounds(
|
||||
self.current_tile
|
||||
@ -2869,6 +2976,13 @@ impl RenderState {
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_current_device_aligned_tile_bounds(&mut self) -> Result<Rect> {
|
||||
Ok(self.get_device_aligned_tile_bounds(
|
||||
self.current_tile
|
||||
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Renders a drop shadow effect for the given shape.
|
||||
///
|
||||
/// Creates a black shadow by converting the original shadow color to black,
|
||||
@ -3296,7 +3410,7 @@ impl RenderState {
|
||||
// Skip it for this pass; a subsequent render will pick it up once present.
|
||||
continue;
|
||||
};
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
let mut extrect: Option<Rect> = None;
|
||||
|
||||
// If the shape is not in the tile set, then we add them.
|
||||
@ -3403,7 +3517,7 @@ impl RenderState {
|
||||
),
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
let scale = self.get_scale();
|
||||
let scale = self.get_raster_scale();
|
||||
let translation = self
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
@ -3671,7 +3785,9 @@ impl RenderState {
|
||||
if !is_empty || self.current_tile_had_shapes {
|
||||
if self.options.is_interactive_transform() {
|
||||
// During drag, avoid snapshot-based caching. Draw Current directly
|
||||
// into Target (and Cache) to reduce stalls.
|
||||
// into Target (and Cache) to reduce stalls. Device bounds so LQ
|
||||
// tiles upscale onto the HiDPI backbuffer.
|
||||
let tile_rect = self.get_current_device_aligned_tile_bounds()?;
|
||||
self.surfaces.draw_current_tile_into_backbuffer(
|
||||
&tile_rect,
|
||||
self.background_color,
|
||||
@ -3801,7 +3917,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
|
||||
@ -4123,6 +4239,22 @@ impl RenderState {
|
||||
self.viewbox.get_scale()
|
||||
}
|
||||
|
||||
/// Doc→pixel scale for *tile raster* surfaces (Current/Fills/…).
|
||||
///
|
||||
/// At full quality this equals [`Self::get_scale`] (`zoom * dpr`). During
|
||||
/// interactive HiDPI LOD, tile textures are only `BASE` px wide so the CTM
|
||||
/// must be `zoom` — otherwise shapes are drawn 2× too large into the tile
|
||||
/// and the soft settle pass looks oversized until the sharp pass replaces it.
|
||||
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
|
||||
}
|
||||
|
||||
pub fn zoom_changed(&self) -> bool {
|
||||
(self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON
|
||||
}
|
||||
|
||||
@ -7,12 +7,27 @@ 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.
|
||||
// Kept in *tile* units (not scaled by DPR): world tile count already matches
|
||||
// DPR=1 (`BASE/zoom`), and interactive LOD keeps raster tiles at 512 px during
|
||||
// zoom refill. Scaling interest by DPR would only inflate the cache surface.
|
||||
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.
|
||||
///
|
||||
/// Matching tile *count* at HiDPI still costs ~4× fill-rate when each tile is
|
||||
/// `512*dpr` px. Interactive quality keeps raster tiles at 512 px (DPR=1 cost)
|
||||
/// and upscales on present; Full quality 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 +38,8 @@ pub struct RenderOptions {
|
||||
/// keeps per-frame flushing enabled (unlike pan/zoom, where
|
||||
/// `render_from_cache` drives target presentation).
|
||||
interactive_transform: bool,
|
||||
/// Tile raster LOD — see [`ContentQuality`].
|
||||
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 +57,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,
|
||||
@ -69,19 +87,45 @@ 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 at the current quality.
|
||||
/// Interactive quality stays at 512 px even when view DPR is 2 so fill-rate
|
||||
/// matches DPR=1 during pan/zoom; Full quality uses `512 * dpr`.
|
||||
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
|
||||
/// Syncs `dpr_viewport_interest_area_threshold` with the configured
|
||||
/// tile-ring size. Interest is intentionally **not** multiplied by DPR:
|
||||
/// tile textures already scale with DPR, so a 1-tile ring keeps the same
|
||||
/// device-pixel margin as DPR=1.
|
||||
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);
|
||||
self.dpr_viewport_interest_area_threshold = self.viewport_interest_area_threshold;
|
||||
}
|
||||
|
||||
/// Sets the devicePixelRatio.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user