Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-04-13 16:51:51 +02:00
commit 2ccaa3f0c5
8 changed files with 471 additions and 154 deletions

View File

@ -289,7 +289,6 @@
;; This prevents errors when processing changes from other pages
(when shape
(wasm.api/process-object shape))))))
(defn initialize-workspace
([team-id file-id]
(initialize-workspace team-id file-id nil))

View File

@ -310,7 +310,8 @@
(:y selected-frame))
rule-area-size (/ rulers/ruler-area-size zoom)
preview-blend (-> refs/workspace-preview-blend
(mf/deref))]
(mf/deref))
shapes-loading? (mf/deref wasm.api/shapes-loading?)]
;; NOTE: We need this page-id dependency to react to it and reset the
;; canvas, even though we are not using `page-id` inside the hook.
@ -341,10 +342,14 @@
init?
(do
(reset! canvas-init? true)
;; Restore previous canvas pixels immediately after context initialization
;; This happens before initialize-viewport is called
(wasm.api/apply-canvas-blur)
(wasm.api/restore-previous-canvas-pixels))
(if (wasm.api/has-captured-pixels?)
;; Page switch: restore previously captured pixels (blurred)
(wasm.api/restore-previous-canvas-pixels)
;; First load: try to draw a blurred page thumbnail
(when-let [frame-id (get page :thumbnail-frame-id)]
(when-let [uri (dm/get-in @st/state [:thumbnails frame-id])]
(wasm.api/draw-thumbnail-to-canvas uri)))))
(pos? retries)
(vreset! timeout-id-ref
@ -387,8 +392,14 @@
(when @canvas-init?
(if (not @initialized?)
(do
(wasm.api/clear-canvas-pixels)
(wasm.api/initialize-viewport base-objects zoom vbox background)
;; Keep the blurred previous-page preview (page switch) or
;; blank canvas (first load) visible while shapes load.
;; The loading overlay is suppressed because on-shapes-ready
;; is set.
(wasm.api/initialize-viewport
base-objects zoom vbox background 1 nil
(fn []
(wasm.api/clear-canvas-pixels)))
(reset! initialized? true)
(mf/set-ref-val! last-file-version-id-ref file-version-id))
(when (and (some? file-version-id)
@ -497,7 +508,7 @@
:width (max 0 (- (:width vbox) rule-area-size))
:height (max 0 (- (:height vbox) rule-area-size))}]]]
[:g {:style {:pointer-events (if disable-events? "none" "auto")}}
[:g {:style {:pointer-events (if (or disable-events? shapes-loading?) "none" "auto")}}
;; Text editor handling:
;; - When text-editor-wasm/v1 is active, contenteditable is rendered in viewport-overlays (HTML DOM)
(when show-text-editor?
@ -596,15 +607,16 @@
:alt? @alt?
:shift? @shift?}])
[:> widgets/frame-titles*
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?
:on-frame-enter on-frame-enter
:on-frame-leave on-frame-leave
:on-frame-select on-frame-select
:focus focus}]
(when-not shapes-loading?
[:> widgets/frame-titles*
{:objects objects-modified
:selected selected
:zoom zoom
:is-show-artboard-names show-artboard-names?
:on-frame-enter on-frame-enter
:on-frame-leave on-frame-leave
:on-frame-select on-frame-select
:focus focus}])
(when show-prototypes?
[:> widgets/frame-flows*

View File

@ -80,10 +80,9 @@
(def ^:const MAX_BUFFER_CHUNK_SIZE (* 256 1024))
(def ^:const DEBOUNCE_DELAY_MS 100)
(def ^:const THROTTLE_DELAY_MS 10)
;; Number of shapes to process before yielding to browser
(def ^:const SHAPES_CHUNK_SIZE 100)
;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
;; Threshold below which we use synchronous processing (no chunking overhead)
(def ^:const ASYNC_THRESHOLD 100)
@ -98,6 +97,12 @@
(def capture-canvas-pixels webgl/capture-canvas-pixels)
(def restore-previous-canvas-pixels webgl/restore-previous-canvas-pixels)
(def clear-canvas-pixels webgl/clear-canvas-pixels)
(def draw-thumbnail-to-canvas webgl/draw-thumbnail-to-canvas)
(defn has-captured-pixels?
"Returns true if there are saved canvas pixels from a previous page."
[]
(some? wasm/canvas-pixels))
;; Re-export public text editor functions
(def text-editor-focus text-editor/text-editor-focus)
@ -123,13 +128,17 @@
;;
(def shape-wrapper-factory nil)
(defn- yield-to-browser
"Returns a promise that resolves after yielding to the browser's event loop.
Uses requestAnimationFrame for smooth visual updates during loading."
[]
(p/create
(fn [resolve _reject]
(js/requestAnimationFrame (fn [_] (resolve nil))))))
(let [^js ch (js/MessageChannel.)]
(defn- yield-to-browser
"Returns a promise that resolves after yielding to the browser's event loop.
Uses MessageChannel for near-zero delay (avoids setTimeout's 4ms minimum
after nesting depth > 5). Same technique used by React's scheduler."
[]
(p/create
(fn [resolve _reject]
(set! (.-onmessage (.-port1 ch))
(fn [_] (resolve nil)))
(.postMessage (.-port2 ch) nil)))))
;; Based on app.main.render/object-svg
(mf/defc object-svg
@ -982,11 +991,18 @@
(letfn [(do-render []
;; Check if context is still initialized before executing
;; to prevent errors when navigating quickly
(when wasm/context-initialized?
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(perf/begin-measure "render-finish")
(h/call wasm/internal-module "_set_view_end")
(perf/end-measure "render-finish")
(render (js/performance.now))))]
;; Use async _render: visible tiles render synchronously
;; (no yield), interest-area tiles render progressively
;; via rAF. _set_view_end already rebuilt the tile
;; index. For pan, most tiles are cached so the render
;; completes in the first frame. For zoom, interest-
;; area tiles (~3 tile margin) don't block the main
;; thread.
(h/call wasm/internal-module "_render" 0)))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(defn set-view-box
@ -1064,54 +1080,62 @@
(set-shape-layout shape)
(set-layout-data shape)
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(let [is-text? (= type :text)
pending_thumbnails (into [] (concat
(when is-text? (set-shape-text-content id content))
(when is-text? (set-shape-text-images id content true))
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(when is-text? (set-shape-text-images id content false))
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full}))))
(defn update-text-layouts
[shapes]
(->> shapes
(filter cfh/text-shape?)
(map :id)
(run!
(fn [id]
(defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates
to the worker index."
[text-ids]
(run! (fn [id]
(f/update-text-layout id)
(update-text-rect! id)))))
(update-text-rect! id))
text-ids))
(defn process-pending
([shapes thumbnails full on-complete]
(process-pending shapes thumbnails full nil on-complete))
([shapes thumbnails full on-render on-complete]
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
[shapes thumbnails full on-complete]
(let [pending-thumbnails
(d/index-by :key :callback thumbnails)
pending-full
(d/index-by :key :callback full)]
pending-full
(d/index-by :key :callback full)]
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback)))
(rx/reduce conj []))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])))
(rx/subs!
(fn [_]
(update-text-layouts shapes)
(if on-render
(on-render)
(request-render "pending-finished")))
noop-fn
on-complete)))))
;; Run text layouts synchronously so shapes are immediately correct.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(if (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback)))
(rx/reduce conj []))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])))
(rx/subs!
(fn [_]
;; Fonts are now loaded — recompute text layouts so Skia
;; uses the real metrics instead of fallback-font estimates.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(request-render "images-loaded"))
noop-fn
(fn [] (when on-complete (on-complete)))))
;; No pending images — complete immediately.
(when on-complete (on-complete)))))
(defn process-object
[shape]
@ -1119,86 +1143,120 @@
(process-pending [shape] thumbnails full noop-fn)))
(defn- process-shapes-chunk
"Process a chunk of shapes synchronously, returning accumulated pending operations.
"Process shapes starting at `start-index` until the time budget is exhausted.
Returns {:thumbnails [...] :full [...] :next-index n}"
[shapes start-index chunk-size thumbnails-acc full-acc]
(let [total (count shapes)
end-index (min total (+ start-index chunk-size))]
[shapes start-index thumbnails-acc full-acc]
(let [total (count shapes)
deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)]
(loop [index start-index
t-acc thumbnails-acc
f-acc full-acc]
(if (< index end-index)
t-acc (transient thumbnails-acc)
f-acc (transient full-acc)]
(if (and (< index total)
;; Check performance.now every 8 shapes to reduce overhead
(or (pos? (bit-and (- index start-index) 7))
(<= (js/performance.now) deadline)))
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into t-acc thumbnails)
(into f-acc full)))
{:thumbnails t-acc
:full f-acc
:next-index end-index}))))
(reduce conj! t-acc thumbnails)
(reduce conj! f-acc full)))
{:thumbnails (persistent! t-acc)
:full (persistent! f-acc)
:next-index index}))))
(defn- set-objects-async
"Asynchronously process shapes in chunks, yielding to the browser between chunks.
Returns a promise that resolves when all shapes are processed.
Renders a preview only periodically during loading to show progress,
then does a full tile-based render at the end."
[shapes render-callback]
(let [total-shapes (count shapes)
total-chunks (mth/ceil (/ total-shapes SHAPES_CHUNK_SIZE))
;; Render at 25%, 50%, 75% of loading
render-at-chunks (set [(mth/floor (* total-chunks 0.25))
(mth/floor (* total-chunks 0.5))
(mth/floor (* total-chunks 0.75))])]
"Asynchronously process shapes in time-budgeted chunks, yielding to the
browser between chunks so the UI stays responsive.
Returns a promise that resolves when all shapes are processed."
[shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes)]
(p/create
(fn [resolve _reject]
(letfn [(process-next-chunk [index thumbnails-acc full-acc chunk-count]
(letfn [(process-next-chunk [index thumbnails-acc full-acc]
(if (< index total-shapes)
;; Process one chunk
;; Process one time-budgeted chunk
(let [{:keys [thumbnails full next-index]}
(process-shapes-chunk shapes index SHAPES_CHUNK_SIZE
thumbnails-acc full-acc)
new-chunk-count (inc chunk-count)]
;; Only render at specific progress milestones
(when (contains? render-at-chunks new-chunk-count)
(render-preview!))
(process-shapes-chunk shapes index
thumbnails-acc full-acc)]
;; Yield to browser, then continue with next chunk
(-> (yield-to-browser)
(p/then (fn [_]
(process-next-chunk next-index thumbnails full new-chunk-count)))))
(process-next-chunk next-index thumbnails full)))))
;; All chunks done - finalize
(do
(perf/end-measure "set-objects")
(process-pending shapes thumbnails-acc full-acc noop-fn
(fn []
(end-shapes-loading!)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil))))))]
(process-next-chunk 0 [] [] 0))))))
;; Notify that shapes are loaded and tiles rebuilt
(when on-shapes-ready (on-shapes-ready))
;; Show shapes immediately: end loading overlay + unblock rendering
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!)
;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end")
;; Text layouts must run after _end_loading (they
;; depend on state that is only correct when loading
;; is false). Each call touch_shape → touched_ids.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(if render-callback
(render-callback)
(request-render "set-objects-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))
(resolve nil)
;; Kick off image fetches in the background.
;; The promise is already resolved so these don't
;; block the caller.
(let [pending-thumbnails (d/index-by :key :callback thumbnails-acc)
pending-full (d/index-by :key :callback full-acc)]
(when (or (seq pending-thumbnails) (seq pending-full))
(->> (rx/concat
(->> (rx/from (vals pending-thumbnails))
(rx/merge-map (fn [callback] (callback)))
(rx/reduce conj []))
(->> (rx/from (vals pending-full))
(rx/mapcat (fn [callback] (callback)))
(rx/reduce conj [])))
(rx/subs!
(fn [_]
;; Fonts are now loaded — recompute text
;; layouts so Skia uses the real metrics
;; instead of fallback-font estimates.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(request-render "images-loaded"))
noop-fn
noop-fn)))))))]
(process-next-chunk 0 [] []))))))
(defn- set-objects-sync
"Synchronously process all shapes (for small shape counts)."
[shapes render-callback]
[shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes)
{:keys [thumbnails full]}
(loop [index 0 thumbnails-acc [] full-acc []]
(loop [index 0 thumbnails-acc (transient []) full-acc (transient [])]
(if (< index total-shapes)
(let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)]
(recur (inc index)
(into thumbnails-acc thumbnails)
(into full-acc full)))
{:thumbnails thumbnails-acc :full full-acc}))]
(reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full)))
{:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))]
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(when on-shapes-ready (on-shapes-ready))
;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end")
(process-pending shapes thumbnails full
(fn []
(if render-callback
(render-callback)
(render-finish))
(request-render "set-objects-sync-complete"))
(ug/dispatch! (ug/event "penpot:wasm:set-objects"))))))
(defn- shapes-in-tree-order
@ -1234,23 +1292,36 @@
"Set all shape objects for rendering.
Shapes are processed in tree order (parents before children)
to maintain proper shape reference consistency in WASM."
to maintain proper shape reference consistency in WASM.
on-shapes-ready is an optional callback invoked right after shapes are
loaded into WASM (and tiles rebuilt for async). It fires before image
loading begins, allowing callers to reveal the page content during
transitions."
([objects]
(set-objects objects nil))
(set-objects objects nil nil))
([objects render-callback]
(set-objects objects render-callback nil))
([objects render-callback on-shapes-ready]
(perf/begin-measure "set-objects")
(let [shapes (shapes-in-tree-order objects)
total-shapes (count shapes)]
(if (< total-shapes ASYNC_THRESHOLD)
(set-objects-sync shapes render-callback)
(set-objects-sync shapes render-callback on-shapes-ready)
(do
(begin-shapes-loading!)
(h/call wasm/internal-module "_begin_loading")
;; NOTE: to render a loading overlay in the future
;; (when-not on-shapes-ready
;; (h/call wasm/internal-module "_render_loading_overlay"))
(try
(-> (set-objects-async shapes render-callback)
(-> (set-objects-async shapes render-callback on-shapes-ready)
(p/catch (fn [error]
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error))))
(catch :default error
(h/call wasm/internal-module "_end_loading")
(end-shapes-loading!)
(js/console.error "Async WASM shape loading failed" error)
(throw error)))
@ -1385,17 +1456,18 @@
(defn initialize-viewport
([base-objects zoom vbox background]
(initialize-viewport base-objects zoom vbox background 1 nil))
(initialize-viewport base-objects zoom vbox background 1 nil nil))
([base-objects zoom vbox background callback]
(initialize-viewport base-objects zoom vbox background 1 callback))
(initialize-viewport base-objects zoom vbox background 1 callback nil))
([base-objects zoom vbox background background-opacity callback]
(initialize-viewport base-objects zoom vbox background background-opacity callback nil))
([base-objects zoom vbox background background-opacity callback on-shapes-ready]
(let [rgba (sr-clr/hex->u32argb background background-opacity)
shapes (into [] (vals base-objects))
total-shapes (count shapes)]
total-shapes (count (vals base-objects))]
(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)
(set-objects base-objects callback))))
(set-objects base-objects callback on-shapes-ready))))
(def ^:private default-context-options
#js {:antialias false

View File

@ -9,7 +9,8 @@
(:require
[app.common.logging :as log]
[app.render-wasm.wasm :as wasm]
[app.util.dom :as dom]))
[app.util.dom :as dom]
[promesa.core :as p]))
(defn get-webgl-context
"Gets the WebGL context from the WASM module"
@ -166,3 +167,32 @@ void main() {
_ (.readPixels ^js context 0 0 width height (.-RGBA ^js context) (.-UNSIGNED_BYTE ^js context) buffer)
image-data (js/ImageData. buffer width height)]
(set! wasm/canvas-pixels image-data))))
(defn draw-thumbnail-to-canvas
"Loads an image from `uri` and draws it stretched to fill the WebGL canvas.
Returns a promise that resolves to true if drawn, false otherwise."
[uri]
(if (and uri wasm/canvas wasm/gl-context)
(p/create
(fn [resolve _reject]
(let [img (js/Image.)]
(set! (.-crossOrigin img) "anonymous")
(set! (.-onload img)
(fn []
(if wasm/gl-context
(let [gl wasm/gl-context
width (.-width wasm/canvas)
height (.-height wasm/canvas)
;; Draw image to an offscreen 2D canvas, scaled to fill
canvas2d (js/OffscreenCanvas. width height)
ctx2d (.getContext canvas2d "2d")]
(.drawImage ctx2d img 0 0 width height)
(let [image-data (.getImageData ctx2d 0 0 width height)]
(draw-imagedata-to-webgl gl image-data))
(resolve true))
(resolve false))))
(set! (.-onerror img)
(fn [_]
(resolve false)))
(set! (.-src img) uri))))
(p/resolved false)))

View File

@ -220,7 +220,13 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()
#[wasm_error]
pub extern "C" fn render_from_cache(_: i32) -> Result<()> {
with_state_mut!(state, {
state.render_state.cancel_animation_frame();
// Don't cancel the animation frame — let the async render
// continue populating the tile HashMap in the background.
// process_animation_frame skips flush_and_submit in fast
// mode so it won't present stale Target content. The
// tile HashMap is position-independent, so tiles rendered
// for the old viewport can be reused by the next full
// render at the new viewport position.
state.render_from_cache();
});
Ok(())
@ -244,6 +250,42 @@ pub extern "C" fn render_preview() -> Result<()> {
Ok(())
}
/// Enter bulk-loading mode. While active, `state.loading` is `true`.
#[no_mangle]
#[wasm_error]
pub extern "C" fn begin_loading() -> Result<()> {
with_state_mut!(state, {
state.loading = true;
});
Ok(())
}
/// Leave bulk-loading mode. Should be called after the first
/// render so the loading flag is available during that render.
#[no_mangle]
#[wasm_error]
pub extern "C" fn end_loading() -> Result<()> {
with_state_mut!(state, {
state.loading = false;
});
Ok(())
}
/// Draw a full-screen loading overlay (background + "Loading…" text).
/// Called from CLJS right after begin_loading so the user sees
/// immediate feedback while shapes are being processed.
/// NOTE:
/// This is currently not being used, but it's set there for testing purposes on
/// upcoming tasks
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_loading_overlay() -> Result<()> {
with_state_mut!(state, {
state.render_state.render_loading_overlay();
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> {
@ -305,8 +347,10 @@ pub extern "C" fn set_view_start() -> Result<()> {
}
/// Finishes a view interaction (zoom or pan). Rebuilds the tile index
/// and invalidates the tile texture cache so the subsequent render
/// re-draws all tiles at full quality (fast_mode is off at this point).
/// and, for zoom changes, invalidates the tile texture cache so the
/// subsequent render re-draws tiles at full quality.
/// For pure pan (same zoom), cached tiles are preserved so only
/// newly-visible tiles need rendering.
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_view_end() -> Result<()> {
@ -323,11 +367,21 @@ pub extern "C" fn set_view_end() -> Result<()> {
if state.render_state.options.is_profile_rebuild_tiles() {
state.rebuild_tiles();
} else if state.render_state.zoom_changed() {
// Zoom changed: tile sizes differ so all cached tile
// textures are invalid (wrong scale). Rebuild the tile
// 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.
state.render_state.rebuild_tile_index(&state.shapes);
state.render_state.surfaces.invalidate_tile_cache();
} else {
// Rebuild tile index + invalidate tile texture cache.
// Cache canvas is preserved so render_from_cache can still
// show a scaled preview during zoom.
state.rebuild_tiles_shallow();
// Pure pan at the same zoom level: tile contents have not
// changed — only the viewport position moved. Update the
// tile index (which tiles are in the interest area) but
// keep cached tile textures so the render can blit them
// instead of re-drawing every visible tile from scratch.
state.render_state.rebuild_tile_index(&state.shapes);
}
performance::end_measure!("set_view_end");

View File

@ -40,6 +40,7 @@ pub use images::*;
// 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.
const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 3;
const MAX_BLOCKING_TIME_MS: i32 = 32;
const NODE_BATCH_THRESHOLD: i32 = 3;
const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0;
@ -344,11 +345,9 @@ pub(crate) struct RenderState {
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> 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,
VIEWPORT_INTEREST_AREA_THRESHOLD,
scale,
);
let interest = VIEWPORT_INTEREST_AREA_THRESHOLD;
let TileRect(isx, isy, iex, iey) =
tiles::get_tiles_for_viewbox_with_interest(viewbox, interest, scale);
let dx = if isx.signum() != iex.signum() { 1 } else { 0 };
let dy = if isy.signum() != iey.signum() { 1 } else { 0 };
@ -658,6 +657,41 @@ impl RenderState {
self.surfaces.reset(self.background_color);
}
/// NOTE:
/// This is currently not being used, but it's set there for testing purposes on
/// upcoming tasks
pub fn render_loading_overlay(&mut self) {
let canvas = self.surfaces.canvas(SurfaceId::Target);
let skia::ISize { width, height } = canvas.base_layer_size();
canvas.save();
// Full-screen background rect
let rect = skia::Rect::from_wh(width as f32, height as f32);
let mut bg_paint = skia::Paint::default();
bg_paint.set_color(self.background_color);
bg_paint.set_style(skia::PaintStyle::Fill);
canvas.draw_rect(rect, &bg_paint);
// Centered "Loading…" text
let mut text_paint = skia::Paint::default();
text_paint.set_color(skia::Color::GRAY);
text_paint.set_anti_alias(true);
let font = self.fonts.debug_font();
// FIXME
let text = "Loading…";
let (text_width, _) = font.measure_str(text, None);
let metrics = font.metrics();
let text_height = metrics.1.cap_height;
let x = (width as f32 - text_width) / 2.0;
let y = (height as f32 + text_height) / 2.0;
canvas.draw_str(text, skia::Point::new(x, y), font, &text_paint);
canvas.restore();
self.flush_and_submit();
}
#[allow(dead_code)]
pub fn get_canvas_at(&mut self, surface_id: SurfaceId) -> &skia::Canvas {
self.surfaces.canvas(surface_id)
@ -669,20 +703,24 @@ impl RenderState {
}
pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> {
let fast_mode = self.options.is_fast_mode();
// Decide *now* (at the first real cache blit) whether we need to clear Cache.
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI),
// while still preventing stale pixels from surviving across full-quality renders.
if !self.options.is_fast_mode() && !self.cache_cleared_this_render {
if !fast_mode && !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true;
}
let tile_rect = self.get_current_aligned_tile_bounds()?;
// In fast mode the viewport is moving (pan/zoom) so Cache surface
// positions would be wrong — only save to the tile HashMap.
self.surfaces.cache_current_tile_texture(
&self.tile_viewbox,
&self
.current_tile
.ok_or(Error::CriticalError("Current tile not found".to_string()))?,
&tile_rect,
fast_mode,
);
self.surfaces.draw_cached_tile_surface(
@ -1419,18 +1457,19 @@ impl RenderState {
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
let scale = self.get_cached_scale();
let cached_scale = self.get_cached_scale();
// Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache)
if self.cached_viewbox.area.width() > 0.0 {
// Scale and translate the target according to the cached data
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
let interest = VIEWPORT_INTEREST_AREA_THRESHOLD;
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(
self.cached_viewbox,
VIEWPORT_INTEREST_AREA_THRESHOLD,
scale,
interest,
cached_scale,
);
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
@ -1453,6 +1492,36 @@ impl RenderState {
// Restore canvas state
self.surfaces.canvas(SurfaceId::Target).restore();
// During pure pan (same zoom), draw tiles from the HashMap
// on top of the scaled Cache surface. Cached tile textures
// include full-quality effects (shadows, blur) from the last
// render, so blitting them avoids re-rendering and keeps pan
// smooth. During zoom the tile grid changes so HashMap tiles
// would be at wrong positions — skip them and let the full
// render after set_view_end handle it.
if !self.zoom_changed() {
let current_scale = self.get_scale();
let visible_rect = tiles::get_tiles_for_viewbox(self.viewbox, current_scale);
let vb_offset_x = self.viewbox.area.left * current_scale;
let vb_offset_y = self.viewbox.area.top * current_scale;
for tx in visible_rect.x1()..=visible_rect.x2() {
for ty in visible_rect.y1()..=visible_rect.y2() {
let tile = tiles::Tile::from(tx, ty);
if self.surfaces.has_cached_tile_surface(tile) {
let tile_rect = skia::Rect::from_xywh(
tx as f32 * tiles::TILE_SIZE - vb_offset_x,
ty as f32 * tiles::TILE_SIZE - vb_offset_y,
tiles::TILE_SIZE,
tiles::TILE_SIZE,
);
self.surfaces
.draw_cached_tile_surface(tile, tile_rect, bg_color);
}
}
}
}
if self.options.is_debug_visible() {
debug::render(self);
}
@ -1559,6 +1628,16 @@ impl RenderState {
self.render_shape_tree_sync(base_object, tree, timestamp)?;
} else {
self.process_animation_frame(base_object, tree, timestamp)?;
// Update cached_viewbox after visible tiles render
// synchronously so that render_from_cache uses the correct
// zoom ratio even if interest-area tiles are still rendering
// asynchronously. Without this, panning right after a zoom
// would keep scaling the Cache surface by the old zoom ratio
// (pixelated/wrong-scale tiles) because the async render
// never completes — each pan frame cancels it.
if self.cache_cleared_this_render {
self.cached_viewbox = self.viewbox;
}
}
performance::end_measure!("start_render_loop");
@ -1577,7 +1656,15 @@ impl RenderState {
if tree.len() != 0 {
self.render_shape_tree_partial(base_object, tree, timestamp, true)?;
}
self.flush_and_submit();
// In fast mode (pan/zoom in progress), render_from_cache owns
// the Target surface — skip flush so we don't present stale
// tile positions. The rAF still populates the Cache surface
// and tile HashMap so render_from_cache progressively shows
// more complete content.
if !self.options.is_fast_mode() {
self.flush_and_submit();
}
if self.render_in_progress {
self.cancel_animation_frame();
@ -1623,7 +1710,10 @@ impl RenderState {
.clear(skia::Color::TRANSPARENT);
if tree.len() != 0 {
let shape = tree.get(id).unwrap();
let Some(shape) = tree.get(id) else {
// FIXME
return Ok((Vec::new(), 0, 0));
};
let mut extrect = shape.extrect(tree, scale);
self.export_context = Some((extrect, scale));
let margins = self.surfaces.margins;
@ -2580,6 +2670,23 @@ impl RenderState {
tile_rect,
self.background_color,
);
// Also draw the cached tile to the Cache surface so
// render_from_cache (used during pan) has the full scene.
// apply_render_to_final_canvas clears Cache on the first
// uncached tile, but cached tiles must also be present.
if !self.options.is_fast_mode() {
if !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color);
self.cache_cleared_this_render = true;
}
let aligned_rect = self.get_aligned_tile_bounds(current_tile);
self.surfaces.draw_cached_tile_to_cache(
current_tile,
&aligned_rect,
self.background_color,
);
}
performance::end_measure!("render_shape_tree::cached");
if self.options.is_debug_visible() {
@ -2678,8 +2785,17 @@ impl RenderState {
self.surfaces.gc();
// Mark cache as valid for render_from_cache
self.cached_viewbox = self.viewbox;
// Mark cache as valid for render_from_cache.
// Only update for full-quality renders (non-fast mode).
// An async render can complete while fast mode is active
// (e.g. interest-area tiles finish during a pan gesture).
// Those tiles lack effects (shadows, blur). Updating
// cached_viewbox here would make zoom_changed() return false,
// so set_view_end would skip tile invalidation and the next
// full render would reuse the low-quality tiles.
if !self.options.is_fast_mode() {
self.cached_viewbox = self.viewbox;
}
if self.options.is_debug_visible() {
debug::render(self);

View File

@ -506,6 +506,7 @@ impl Surfaces {
self.canvas(SurfaceId::Fills).restore_to_count(1);
self.canvas(SurfaceId::InnerShadows).restore_to_count(1);
self.canvas(SurfaceId::TextDropShadows).restore_to_count(1);
self.canvas(SurfaceId::DropShadows).restore_to_count(1);
self.canvas(SurfaceId::Strokes).restore_to_count(1);
self.canvas(SurfaceId::Current).restore_to_count(1);
self.canvas(SurfaceId::Export).restore_to_count(1);
@ -515,6 +516,7 @@ impl Surfaces {
| SurfaceId::Current as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32
| SurfaceId::DropShadows as u32
| SurfaceId::Export as u32,
|s| {
s.canvas().clear(color).reset_matrix();
@ -547,6 +549,7 @@ impl Surfaces {
tile_viewbox: &TileViewbox,
tile: &Tile,
tile_rect: &skia::Rect,
skip_cache_surface: bool,
) {
let rect = IRect::from_xywh(
self.margins.width,
@ -558,13 +561,15 @@ impl Surfaces {
let tile_image_opt = self.current.image_snapshot_with_bounds(rect);
if let Some(tile_image) = tile_image_opt {
// Draw to cache first (takes reference), then move to tile cache
self.cache.canvas().draw_image_rect(
&tile_image,
None,
tile_rect,
&skia::Paint::default(),
);
if !skip_cache_surface {
// Draw to cache surface for render_from_cache
self.cache.canvas().draw_image_rect(
&tile_image,
None,
tile_rect,
&skia::Paint::default(),
);
}
self.tiles.add(tile_viewbox, tile, tile_image);
}
@ -594,6 +599,29 @@ impl Surfaces {
}
}
/// Draws a cached tile texture to the Cache surface at the given
/// cache-aligned rect. This keeps the Cache surface in sync with
/// Target so that `render_from_cache` (used during pan) has the
/// full scene including tiles served from the texture cache.
pub fn draw_cached_tile_to_cache(
&mut self,
tile: Tile,
aligned_rect: &skia::Rect,
color: skia::Color,
) {
if let Some(image) = self.tiles.get(tile) {
let mut bg = skia::Paint::default();
bg.set_color(color);
self.cache.canvas().draw_rect(aligned_rect, &bg);
self.cache.canvas().draw_image_rect(
&image,
None,
aligned_rect,
&skia::Paint::default(),
);
}
}
/// Draws the current tile directly to the target and cache surfaces without
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
/// populate the tile texture cache (suitable for one-shot renders like tests).

View File

@ -26,6 +26,8 @@ pub(crate) struct State {
pub current_browser: u8,
pub shapes: ShapesPool,
pub saved_shapes: Option<ShapesPool>,
/// True while the first bulk load of shapes is in progress.
pub loading: bool,
}
impl State {
@ -36,8 +38,8 @@ impl State {
current_id: None,
current_browser: 0,
shapes: ShapesPool::new(),
// TODO: Maybe this can be moved to a different object
saved_shapes: None,
loading: false,
})
}
@ -285,12 +287,16 @@ impl State {
}
pub fn touch_current(&mut self) {
if let Some(current_id) = self.current_id {
self.render_state.mark_touched(current_id);
if !self.loading {
if let Some(current_id) = self.current_id {
self.render_state.mark_touched(current_id);
}
}
}
pub fn touch_shape(&mut self, id: Uuid) {
self.render_state.mark_touched(id);
if !self.loading {
self.render_state.mark_touched(id);
}
}
}