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:
Alejandro Alonso 2026-08-04 09:10:46 +02:00
parent 4b9c94c0da
commit ac203569c9
3 changed files with 258 additions and 76 deletions

View File

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

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

View File

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