mirror of
https://github.com/penpot/penpot.git
synced 2026-08-25 22:29:03 +00:00
⚡ Present viewport before interest and clamp paint to atlas (#11313)
Present visible tiles via ViewportReady so zoom settle turns sharp without waiting on the interest ring, and paint at atlas slot size so DPR 2 does not rasterize 1024 only to downscale into 512 slots.
This commit is contained in:
parent
81c3b3cd56
commit
57c0e81616
@ -19,6 +19,9 @@
|
||||
|
||||
- Raster `Fill::Image`: skip `save_layer` unless the shape has an image filter; plain
|
||||
Rect/Frame (no corners) also skip the container clip (`draw_image_fill` in fills.rs).
|
||||
- Zoom settle: visible tiles present via `FrameType::ViewportReady` before interest-ring
|
||||
work; crop-cache rebuild is deferred to the later `Full` so the soft→sharp snap is
|
||||
compose+present only.
|
||||
- Interactive transforms are distinct from viewport fast mode. `set_modifiers_start` enables fast mode and interactive transform; interactive transform still flushes each animation frame.
|
||||
- During interactive transform, modifier tile invalidation is deferred to `render()` once per rAF. Outside interactive transform, `set_modifiers` rebuilds modifier tiles immediately.
|
||||
- `set_modifiers_end` disables fast/interactive state and cancels pending async render; the caller must request the final full-quality render.
|
||||
|
||||
@ -58,6 +58,7 @@
|
||||
WebGL is ready before re-init finishes."
|
||||
[]
|
||||
(set! internal-frame-id nil)
|
||||
(set! internal-frame-type 0)
|
||||
(set! canvas nil)
|
||||
(set! canvas-snapshot nil)
|
||||
(set! gl-context-handle nil)
|
||||
|
||||
@ -389,6 +389,19 @@
|
||||
(def ^:const FRAME_TYPE_NONE 0) ;; This type should never "leak".
|
||||
(def ^:const FRAME_TYPE_PARTIAL 1) ;; A frame needs more render calls to end.
|
||||
(def ^:const FRAME_TYPE_FULL 2) ;; A frame was full.
|
||||
(def ^:const FRAME_TYPE_VIEWPORT_READY 3) ;; Viewport presented; interest tiles may still be pending.
|
||||
|
||||
(defn- needs-more-render-frames?
|
||||
"True when WASM still has progressive tile work (visible or interest ring)."
|
||||
[]
|
||||
(or (= wasm/internal-frame-type FRAME_TYPE_PARTIAL)
|
||||
(= wasm/internal-frame-type FRAME_TYPE_VIEWPORT_READY)))
|
||||
|
||||
(defn- frame-presented-target?
|
||||
"True when this frame recomposited Target (full or early viewport present)."
|
||||
[]
|
||||
(not= wasm/internal-frame-type FRAME_TYPE_PARTIAL))
|
||||
|
||||
(def ^:const RENDER-FLAG-SYNC-TILES 4) ;; Rebuild tile index without ending fast mode (pan/zoom pause).
|
||||
|
||||
(defn- internal-render
|
||||
@ -398,7 +411,7 @@
|
||||
(internal-render timestamp wasm/internal-frame-type))
|
||||
([timestamp flags]
|
||||
(set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp flags))
|
||||
(when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL)
|
||||
(when (needs-more-render-frames?)
|
||||
(request-render "frame-type-partial"))))
|
||||
|
||||
(defn- build-reload-payload
|
||||
@ -495,13 +508,13 @@
|
||||
(when (is-text-editor-wasm-enabled @st/state)
|
||||
(text-editor/text-editor-update-blink timestamp)
|
||||
;; Only repaint the overlay when this frame recomposited Target (a full
|
||||
;; frame). A partial frame is flushed but not presented — Target still
|
||||
;; shows the last presented frame with the overlay already on it — so
|
||||
;; repainting the translucent selection over it stacks another layer
|
||||
;; every progressive frame: it darkens, then snaps back when the final
|
||||
;; frame presents from the clean Backbuffer (the blink at the end of a
|
||||
;; zoom over a selection, gh-10709).
|
||||
(when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL)
|
||||
;; frame or early viewport present). A partial frame is flushed but not
|
||||
;; presented - Target still shows the last presented frame with the
|
||||
;; overlay already on it - so repainting the translucent selection over
|
||||
;; it stacks another layer every progressive frame: it darkens, then
|
||||
;; snaps back when the final frame presents from the clean Backbuffer
|
||||
;; (the blink at the end of a zoom over a selection, gh-10709).
|
||||
(when (frame-presented-target?)
|
||||
(text-editor/text-editor-render-overlay))
|
||||
;; Drain editor events. Only content/layout changes need a full shape
|
||||
;; re-render; selection/style changes are already reflected by the
|
||||
@ -610,12 +623,31 @@
|
||||
(defonce shapes-loading? (atom false))
|
||||
(defonce deferred-render? (atom false))
|
||||
|
||||
;; Each `request-render` captures this number. `stop-progressive-render!`
|
||||
;; increments it so already-scheduled rAFs become no-ops.
|
||||
;;
|
||||
;; Why: ViewportReady asks for another frame for the interest ring. If the
|
||||
;; page changes before that frame runs, `_init` leaves an empty shapes pool
|
||||
;; and `_render` panics ("Root shape not found"). Cancel alone is not enough
|
||||
;; when the current `_render` itself schedules the next rAF after we cancelled.
|
||||
(defonce ^:private render-seq* (atom 0))
|
||||
|
||||
(defn render-pending?
|
||||
"True while a render has been scheduled but not yet completed — including the
|
||||
frames of an in-progress progressive render."
|
||||
[]
|
||||
@pending-render)
|
||||
|
||||
(defn- stop-progressive-render!
|
||||
"Cancel the pending tile-pass rAF and invalidate any follow-ups it may schedule."
|
||||
[]
|
||||
(swap! render-seq* inc)
|
||||
(when-let [frame-id wasm/internal-frame-id]
|
||||
(timers/cancel-af! frame-id)
|
||||
(set! wasm/internal-frame-id nil))
|
||||
(reset! pending-render false)
|
||||
(set! wasm/internal-frame-type FRAME_TYPE_NONE))
|
||||
|
||||
(defn- register-deferred-render!
|
||||
[]
|
||||
(reset! deferred-render? true))
|
||||
@ -629,19 +661,21 @@
|
||||
(register-deferred-render!)
|
||||
(when-not @pending-render
|
||||
(reset! pending-render true)
|
||||
(let [frame-id
|
||||
(timers/raf
|
||||
(fn [ts]
|
||||
(reset! pending-render false)
|
||||
(set! wasm/internal-frame-id nil)
|
||||
(try
|
||||
(render ts)
|
||||
(catch :default e
|
||||
;; A failed render (e.g. a WASM panic) must not strand an
|
||||
;; active page-transition. Force ending of it so the
|
||||
;; workspace is shown without a blur.
|
||||
(end-page-transition!)
|
||||
(throw e)))))]
|
||||
(let [seq-n @render-seq*
|
||||
frame-id (timers/raf
|
||||
(fn [ts]
|
||||
;; Dropped if `stop-progressive-render!` ran since we scheduled.
|
||||
(when (= seq-n @render-seq*)
|
||||
(reset! pending-render false)
|
||||
(set! wasm/internal-frame-id nil)
|
||||
(try
|
||||
(render ts)
|
||||
(catch :default e
|
||||
;; A failed render (e.g. a WASM panic) must not strand an
|
||||
;; active page-transition. Force ending of it so the
|
||||
;; workspace is shown without a blur.
|
||||
(end-page-transition!)
|
||||
(throw e))))))]
|
||||
(set! wasm/internal-frame-id frame-id))))))
|
||||
|
||||
(defn request-render-preserving-target
|
||||
@ -664,12 +698,8 @@
|
||||
(defn- begin-shapes-loading!
|
||||
[]
|
||||
(reset! shapes-loading? true)
|
||||
(let [frame-id wasm/internal-frame-id
|
||||
was-pending @pending-render]
|
||||
(when frame-id
|
||||
(js/cancelAnimationFrame frame-id)
|
||||
(set! wasm/internal-frame-id nil))
|
||||
(reset! pending-render false)
|
||||
(let [was-pending @pending-render]
|
||||
(stop-progressive-render!)
|
||||
(reset! deferred-render? was-pending)))
|
||||
|
||||
(defn- end-shapes-loading!
|
||||
@ -1425,14 +1455,14 @@
|
||||
|
||||
(defn- render-text-editor-overlay-after-frame!
|
||||
"Repaint the overlay after a direct `internal-render`, but only when that
|
||||
render recomposited Target (a full frame). A partial frame is only flushed —
|
||||
Target keeps the last presented frame with the overlay already on it — so
|
||||
repainting the translucent selection then stacks another layer and it visibly
|
||||
darkens across the progressive frames before snapping back on the final
|
||||
present (the blink at the end of a zoom over a selection, gh-10709). The
|
||||
final full frame's own repaint keeps the overlay in place."
|
||||
render recomposited Target (a full frame or early viewport present). A partial
|
||||
frame is only flushed - Target keeps the last presented frame with the overlay
|
||||
already on it - so repainting the translucent selection then stacks another
|
||||
layer and it visibly darkens across the progressive frames before snapping
|
||||
back on the final present (the blink at the end of a zoom over a selection,
|
||||
gh-10709). The final full frame's own repaint keeps the overlay in place."
|
||||
[]
|
||||
(when (not= wasm/internal-frame-type FRAME_TYPE_PARTIAL)
|
||||
(when (frame-presented-target?)
|
||||
(render-text-editor-overlay-if-active!)))
|
||||
|
||||
(defn finalize-view-interaction!
|
||||
@ -2096,6 +2126,8 @@
|
||||
(let [rgba (when background (sr-clr/hex->u32argb background background-opacity))
|
||||
total-shapes (count (vals base-objects))]
|
||||
|
||||
;; Stop Partial/ViewportReady follow-ups before we clear the shapes pool.
|
||||
(stop-progressive-render!)
|
||||
(when rgba (h/call wasm/internal-module "_set_canvas_background" rgba))
|
||||
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
|
||||
(h/call wasm/internal-module "_init_shapes_pool" total-shapes)
|
||||
@ -2378,12 +2410,9 @@
|
||||
|
||||
(set! wasm/context-initialized? false)
|
||||
|
||||
;; Cancel any pending animation frame to prevent race conditions.
|
||||
(when wasm/internal-frame-id
|
||||
(timers/cancel-af! wasm/internal-frame-id))
|
||||
(stop-progressive-render!)
|
||||
|
||||
;; Reset render flags to prevent new renders from being scheduled.
|
||||
(reset! pending-render false)
|
||||
;; Reset remaining render flags so teardown cannot schedule work.
|
||||
(reset! shapes-loading? false)
|
||||
(reset! deferred-render? false)
|
||||
(reset! view-interaction-active? false)
|
||||
|
||||
@ -49,6 +49,10 @@ pub enum FrameType {
|
||||
None = 0,
|
||||
Partial = 1,
|
||||
Full = 2,
|
||||
/// Viewport tiles are presented; interest-ring work may still be pending.
|
||||
/// Frontend should keep requesting frames (like Partial) but may treat the
|
||||
/// Target as freshly composited (like Full) for overlays.
|
||||
ViewportReady = 3,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@ -419,6 +423,9 @@ pub(crate) struct RenderState {
|
||||
pub drop_shadows_ops_warmed: bool,
|
||||
/// Filter-surface snapshots for drop shadows, reused across tiles.
|
||||
drop_shadow_filter_cache: shadows::DropShadowFilterCache,
|
||||
/// Visible tiles were already presented this pass; interest-ring fill may
|
||||
/// still be running. Final Full should not re-present.
|
||||
pub viewport_presented: bool,
|
||||
}
|
||||
|
||||
pub struct InteractiveDragCrop {
|
||||
@ -607,6 +614,7 @@ impl RenderState {
|
||||
tile_atlas_flushed: false,
|
||||
drop_shadows_ops_warmed: false,
|
||||
drop_shadow_filter_cache: shadows::DropShadowFilterCache::new(),
|
||||
viewport_presented: false,
|
||||
})
|
||||
}
|
||||
|
||||
@ -2309,6 +2317,7 @@ impl RenderState {
|
||||
// reorder by distance to the center.
|
||||
self.current_tile = None;
|
||||
self.drop_shadow_filter_cache.clear();
|
||||
self.viewport_presented = false;
|
||||
}
|
||||
|
||||
pub fn start_render_loop(
|
||||
@ -2481,9 +2490,14 @@ impl RenderState {
|
||||
self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?;
|
||||
|
||||
// `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not
|
||||
// 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) {
|
||||
// presented (only flushed), so defer composition until the viewport is
|
||||
// ready and avoid re-snapshotting up to 4096² on every rAF during async
|
||||
// tile work.
|
||||
let should_compose = !self.options.is_interactive_transform()
|
||||
&& matches!(frame_type, FrameType::Full | FrameType::ViewportReady)
|
||||
&& !self.viewport_presented;
|
||||
|
||||
if should_compose {
|
||||
self.surfaces.draw_tile_atlas_to_backbuffer(
|
||||
&self.viewbox,
|
||||
&self.tile_viewbox,
|
||||
@ -2500,17 +2514,31 @@ impl RenderState {
|
||||
// `drain_partial_gpu_soft`). Full still submits via present_frame.
|
||||
Self::drain_partial_gpu_soft();
|
||||
}
|
||||
FrameType::ViewportReady => {
|
||||
// Visible tiles are done: present now so the user sees the
|
||||
// viewport without waiting for interest-ring pre-render.
|
||||
// Defer crop-cache rebuild to Full — it is expensive on large
|
||||
// HiDPI viewports and is not needed until the next drag.
|
||||
self.present_frame(tree);
|
||||
self.viewport_presented = true;
|
||||
wapi::notify_tiles_render_complete!();
|
||||
Self::drain_partial_gpu_soft();
|
||||
}
|
||||
FrameType::Full => {
|
||||
// A full-quality frame is now complete. Rebuild the per-shape crop
|
||||
// cache from the clean Backbuffer (no UI overlay yet) so that
|
||||
// interactive drag backgrounds don't include the grid overlay.
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
if !self.viewport_presented {
|
||||
// A full-quality frame is now complete (no early viewport
|
||||
// present). Rebuild crop cache and present.
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
self.present_frame(tree);
|
||||
wapi::notify_tiles_render_complete!();
|
||||
} else if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
// Interest fill finished after ViewportReady. Backbuffer
|
||||
// still holds the viewport compose; rebuild crop cache
|
||||
// off the sharp-snap frame.
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
// present_frame: copy clean Backbuffer → Target, draw UI/debug
|
||||
// overlays on Target only, then flush. Backbuffer stays overlay-free.
|
||||
self.present_frame(tree);
|
||||
wapi::notify_tiles_render_complete!();
|
||||
performance::end_measure!("render");
|
||||
}
|
||||
}
|
||||
@ -4015,8 +4043,18 @@ impl RenderState {
|
||||
flattened: false,
|
||||
}));
|
||||
} else {
|
||||
// If there are no more pending tiles, stop.
|
||||
should_stop = true;
|
||||
// Visible tiles finished. Promote deferred interest-ring work
|
||||
// so pan/zoom pre-render still happens, but yield first when
|
||||
// allowed so continue_render_loop can present the viewport.
|
||||
if self.pending_tiles.promote_deferred_interest() {
|
||||
if allow_stop {
|
||||
should_stop = true;
|
||||
}
|
||||
// Sync path (allow_stop=false): keep looping on interest
|
||||
// tiles in the same call without an early present.
|
||||
} else {
|
||||
should_stop = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4034,6 +4072,12 @@ impl RenderState {
|
||||
self.cached_viewbox = self.viewbox;
|
||||
}
|
||||
|
||||
// Visible done with interest still queued and we yielded: present
|
||||
// viewport now, keep Partial-like rAFs for the ring.
|
||||
if allow_stop && !self.pending_tiles.list.is_empty() {
|
||||
return Ok(FrameType::ViewportReady);
|
||||
}
|
||||
|
||||
Ok(FrameType::Full)
|
||||
}
|
||||
|
||||
|
||||
@ -352,6 +352,8 @@ pub struct PendingTiles {
|
||||
pub visible_uncached: Vec<Tile>,
|
||||
pub interest_cached: Vec<Tile>,
|
||||
pub interest_uncached: Vec<Tile>,
|
||||
/// Interest-ring tiles deferred until after the viewport has been presented.
|
||||
deferred_interest: Vec<Tile>,
|
||||
}
|
||||
|
||||
impl PendingTiles {
|
||||
@ -364,14 +366,16 @@ impl PendingTiles {
|
||||
visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
interest_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
deferred_interest: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
|
||||
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
|
||||
// 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.
|
||||
@ -423,10 +427,28 @@ impl PendingTiles {
|
||||
}
|
||||
}
|
||||
|
||||
self.list.extend(self.interest_uncached.iter());
|
||||
self.list.extend(self.interest_cached.iter());
|
||||
self.list.extend(self.visible_uncached.iter());
|
||||
self.list.extend(self.visible_cached.iter());
|
||||
// Visible tiles first. Interest-ring work is deferred so we can present
|
||||
// as soon as the viewport is ready (see `promote_deferred_interest`).
|
||||
// Interactive/`only_visible` already excludes the ring from `tile_rect`.
|
||||
if only_visible {
|
||||
self.list.extend(self.visible_uncached.iter());
|
||||
self.list.extend(self.visible_cached.iter());
|
||||
} else {
|
||||
self.deferred_interest.extend(self.interest_uncached.iter());
|
||||
self.deferred_interest.extend(self.interest_cached.iter());
|
||||
self.list.extend(self.visible_uncached.iter());
|
||||
self.list.extend(self.visible_cached.iter());
|
||||
}
|
||||
}
|
||||
|
||||
/// Move deferred interest-ring tiles onto the pending list.
|
||||
/// Returns true when there is interest work left to do.
|
||||
pub fn promote_deferred_interest(&mut self) -> bool {
|
||||
if self.deferred_interest.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.list.append(&mut self.deferred_interest);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn pop(&mut self) -> Option<Tile> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user