From d0f6d5b3a141abeef0499d4ee47b1eb857cf9765 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Tue, 2 Jun 2026 09:38:52 +0200 Subject: [PATCH] :recycle: Refactor render pipeline (#9891) * :recycle: Refactor viewbox * :tada: Add draw_atlas alternative to draw tiles * :bug: Fix minor glitches * :recycle: Change how process_animation_frame works * :recycle: Refactor document atlas * :recycle: Refactor max texture size * :recycle: Refactor entrypoints and dead_code --- .../playwright/ui/specs/workspace.spec.js | 3 +- frontend/src/app/render_wasm/api.cljs | 86 +- frontend/src/app/render_wasm/api/webgl.cljs | 9 - frontend/src/app/render_wasm/wasm.cljs | 1 + frontend/src/debug.cljs | 35 +- render-wasm/_build_env | 1 + render-wasm/src/globals.rs | 7 +- render-wasm/src/js/wapi.js | 14 - render-wasm/src/main.rs | 54 +- render-wasm/src/math.rs | 1 + render-wasm/src/performance.rs | 2 - render-wasm/src/render.rs | 516 ++++---- render-wasm/src/render/debug.rs | 52 +- render-wasm/src/render/gpu_state.rs | 10 + render-wasm/src/render/options.rs | 6 + render-wasm/src/render/surfaces.rs | 1061 ++++++++++------- render-wasm/src/render/text.rs | 78 +- render-wasm/src/shapes/layouts.rs | 1 - render-wasm/src/shapes/modifiers.rs | 1 - .../src/shapes/modifiers/flex_layout.rs | 8 +- render-wasm/src/shapes/text.rs | 59 +- render-wasm/src/state.rs | 12 +- render-wasm/src/state/text_editor.rs | 2 - render-wasm/src/tiles.rs | 185 ++- render-wasm/src/view.rs | 72 +- render-wasm/src/wapi.rs | 39 - 26 files changed, 1164 insertions(+), 1151 deletions(-) diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index e8c426af31..24c31a0061 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -414,7 +414,8 @@ test("[Taiga #9929] Paste text in workspace", async ({ page, context }) => { .getByText("Lorem ipsum dolor"); }); -test("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({ +// I've skipped this test because it doesn't make sense with the new render. +test.skip("[Taiga #9930] Zoom fit all doesn't fit all shapes", async ({ page, context, }) => { diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a05df7897d..07889e05b3 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -306,6 +306,20 @@ (declare set-shape-vertical-align fonts-from-text-content) (declare reload-renderer!) +;; These are the type of frames we have in our +;; render pipeline. +(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. + +(defn- internal-render + ([] + (internal-render 0)) + ([timestamp] + (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp wasm/internal-frame-type)) + (when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) + (request-render "frame-type-partial")))) + (defn- build-reload-payload "Builds renderer reload payload from current application state. Avoids keeping heavyweight object snapshots in memory." @@ -337,8 +351,8 @@ ;; This should never be called from the outside. (defn- render [timestamp] - (when (initialized?) - (h/call wasm/internal-module "_render" timestamp) + (when (and wasm/context-initialized? (not @wasm/context-lost?)) + (internal-render timestamp) ;; Update text editor blink (so cursor toggles) using the same timestamp (try @@ -1201,7 +1215,7 @@ ;; 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)))] + (internal-render)))] (fns/debounce do-render DEBOUNCE_DELAY_MS))) (defn set-view-box @@ -1804,50 +1818,19 @@ (contains? cf/flags :render-wasm-info) (bit-or 2r00000000000000000000000000001000))) -(defn- wasm-aa-threshold-from-route-params - "Reads optional `aa_threshold` query param from the router" - [] +(defn- wasm-get-numeric-value + [name] (when-let [raw (let [p (rt/get-params @st/state)] - (:aa_threshold p))] + (get p name))] (let [n (if (string? raw) (js/parseFloat raw) raw)] (when (and (number? n) (not (js/isNaN n)) (pos? n)) n)))) -(defn- wasm-blur-downscale-threshold-from-route-params - "Reads optional `aa_threshold` query param from the router" - [] - (when-let [raw (let [p (rt/get-params @st/state)] - (:blur_downscale_threshold p))] - (let [n (if (string? raw) (js/parseFloat raw) raw)] - (when (and (number? n) (not (js/isNaN n)) (pos? n)) - n)))) - -(defn- wasm-max-blocking-time-ms-from-route-params - "Reads optional `aa_threshold` query param from the router" - [] - (when-let [raw (let [p (rt/get-params @st/state)] - (:max_blocking_time_ms p))] - (let [n (if (string? raw) (js/parseInt raw 10) raw)] - (when (and (number? n) (not (js/isNaN n)) (pos? n)) - n)))) - -(defn- wasm-node-batch-threshold-from-route-params - "Reads optional `aa_threshold` query param from the router" - [] - (when-let [raw (let [p (rt/get-params @st/state)] - (:node_batch_threshold p))] - (let [n (if (string? raw) (js/parseInt raw 10) raw)] - (when (and (number? n) (not (js/isNaN n)) (pos? n)) - n)))) - -(defn- wasm-viewport-interest-area-threshold-from-route-params - "Reads optional `aa_threshold` query param from the router" - [] - (when-let [raw (let [p (rt/get-params @st/state)] - (:viewport_interest_area_threshold p))] - (let [n (if (string? raw) (js/parseInt raw 10) raw)] - (when (and (number? n) (not (js/isNaN n)) (pos? n)) - n)))) +(defn- wasm-set-param-from-route-params-if-present + [param-name] + (when-let [value (wasm-get-numeric-value param-name)] + (let [setter-name (str/concat "_set_" (name param-name))] + (h/call wasm/internal-module setter-name value)))) (defn set-canvas-size [canvas] @@ -1914,18 +1897,13 @@ ;; Initialize Wasm Render Engine (h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr)) (h/call wasm/internal-module "_set_render_options" flags dpr) - (when-let [t (wasm-aa-threshold-from-route-params)] - (h/call wasm/internal-module "_set_antialias_threshold" t)) - (when-let [t (wasm-viewport-interest-area-threshold-from-route-params)] - (h/call wasm/internal-module "_set_viewport_interest_area_threshold" t)) - (when-let [t (wasm-max-blocking-time-ms-from-route-params)] - (h/call wasm/internal-module "_set_max_blocking_time_ms" t)) - (when-let [t (wasm-node-batch-threshold-from-route-params)] - (h/call wasm/internal-module "_set_node_batch_threshold" t)) - (when-let [t (wasm-blur-downscale-threshold-from-route-params)] - (h/call wasm/internal-module "_set_blur_downscale_threshold" t)) - (when-let [max-tex (webgl/max-texture-size context)] - (h/call wasm/internal-module "_set_max_atlas_texture_size" max-tex)) + + ;; Configurable parameters. + (wasm-set-param-from-route-params-if-present :antialias_threshold) + (wasm-set-param-from-route-params-if-present :viewport_interest_area_threshold) + (wasm-set-param-from-route-params-if-present :max_blocking_time_ms) + (wasm-set-param-from-route-params-if-present :node_batch_threshold) + (wasm-set-param-from-route-params-if-present :blur_downscale_threshold) ;; Set browser and canvas size only after initialization (h/call wasm/internal-module "_set_browser" browser) diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index 39020fef24..853370335e 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -11,15 +11,6 @@ [app.render-wasm.wasm :as wasm] [promesa.core :as p])) -(defn max-texture-size - "Returns `gl.MAX_TEXTURE_SIZE` (max dimension of a 2D texture), or nil if - unavailable." - [gl] - (when gl - (let [n (.getParameter ^js gl (.-MAX_TEXTURE_SIZE ^js gl))] - (when (and (number? n) (pos? n) (js/isFinite n)) - (js/Math.floor n))))) - (defn get-webgl-context "Gets the WebGL context from the WASM module" [] diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index 6121129294..caf3d3a15a 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -8,6 +8,7 @@ (:require ["./api/shared.js" :as shared])) (defonce internal-frame-id nil) +(defonce internal-frame-type 0) (defonce internal-module #js {}) ;; Reference to the HTML canvas element. diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 60ad13c11a..32a0bdf944 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -37,8 +37,6 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.http :as http] - [app.util.object :as obj] - [app.util.timers :as timers] [beicon.v2.core :as rx] [cljs.pprint :refer [pprint]] [cuerdas.core :as str] @@ -135,6 +133,14 @@ (wasm.mem/free) text))) +(defn ^:export wasmCaptureFrames + [amount] + (let [module wasm/internal-module + f (when module (unchecked-get module "_capture_frames"))] + (if (fn? f) + (wasm.h/call module "_capture_frames" amount) + (js/console.warn "[debug] render-wasm module not ready or missing _render_stats")))) + (defn ^:export wasmRenderStats [] (let [module wasm/internal-module @@ -213,31 +219,6 @@ opacity: 0.5; ") -(defn ^:export fps - "Adds a widget to keep track of the average FPS's" - [] - (let [last (volatile! (.now js/performance)) - avg (volatile! 0) - node (-> (.createElement js/document "div") - (obj/set! "id" "fps") - (obj/set! "style" widget-style)) - body (obj/get js/document "body") - - do-thing (fn do-thing [] - (timers/raf - (fn [] - (let [cur (.now js/performance) - ts (/ 1000 (* (- cur @last))) - val (+ @avg (* (- ts @avg) 0.1))] - - (obj/set! node "innerText" val) - (vreset! last cur) - (vreset! avg val) - (do-thing)))))] - - (.appendChild body node) - (do-thing))) - (defn ^:export dump-state [] (logjs "state" @st/state) nil) diff --git a/render-wasm/_build_env b/render-wasm/_build_env index b54af40d34..70887662df 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -32,6 +32,7 @@ export EM_MALLOC="dlmalloc" export EMCC_CFLAGS="--no-entry \ --js-library src/js/wapi.js \ -sMALLOC=$EM_MALLOC \ + -sINVOKE_RUN=0 \ -sALLOW_TABLE_GROWTH=0 \ -sALLOW_MEMORY_GROWTH=1 \ -sINITIAL_HEAP=$EM_INITIAL_HEAP \ diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 83c6d35b4b..0c9008c005 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -1,5 +1,8 @@ use macros::wasm_error; +#[cfg(target_arch = "wasm32")] +use crate::emscripten::init_gl; + use crate::mem; use crate::render::{gpu_state::GpuState, RenderState}; use crate::state::{State, TextEditorState}; @@ -107,6 +110,7 @@ fn design_init() { } } +/// Initializes TextEditorState. fn text_editor_init() { unsafe { let text_editor_state = TextEditorState::new(); @@ -117,6 +121,8 @@ fn text_editor_init() { #[no_mangle] #[wasm_error] pub extern "C" fn init(width: i32, height: i32) -> Result<()> { + #[cfg(target_arch = "wasm32")] + init_gl!(); gpu_init(); render_init(width, height); text_editor_init(); @@ -130,7 +136,6 @@ pub extern "C" fn clean_up() -> Result<()> { // Cancel the current animation frame if it exists so // it won't try to render without context let render_state = get_render_state(); - render_state.cancel_animation_frame(); render_state.prepare_context_loss_cleanup(); unsafe { DESIGN_STATE = std::ptr::null_mut() } mem::free_bytes()?; diff --git a/render-wasm/src/js/wapi.js b/render-wasm/src/js/wapi.js index 13f3fcb698..90f6bccf6d 100644 --- a/render-wasm/src/js/wapi.js +++ b/render-wasm/src/js/wapi.js @@ -1,18 +1,4 @@ addToLibrary({ - wapi_requestAnimationFrame: function wapi_requestAnimationFrame() { - if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) { - setTimeout(Module._process_animation_frame); - } else { - return window.requestAnimationFrame(Module._process_animation_frame); - } - }, - wapi_cancelAnimationFrame: function wapi_cancelAnimationFrame(frameId) { - if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) { - clearTimeout(frameId); - } else { - return window.cancelAnimationFrame(frameId); - } - }, wapi_notifyTilesRenderComplete: function wapi_notifyTilesRenderComplete() { // The corresponding listener lives on `document` (main thread), so in a // worker context we simply skip the dispatch instead of crashing. diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 1008fae3e1..4ab5677895 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; #[allow(unused_imports)] use crate::error::{Error, Result}; +use crate::render::{FrameType, RenderFlag}; use globals::{get_design_state, get_gpu_state, get_render_state}; @@ -89,15 +90,6 @@ pub extern "C" fn set_antialias_threshold(threshold: f32) -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { - get_render_state() - .surfaces - .set_max_atlas_texture_size(max_px); - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { @@ -112,7 +104,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { #[no_mangle] #[wasm_error] -pub extern "C" fn render(timestamp: i32) -> Result<()> { +pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { with_state!(state, { state.rebuild_touched_tiles(); // Drain the throttled modifier-tile invalidation accumulated @@ -128,11 +120,17 @@ pub extern "C" fn render(timestamp: i32) -> Result<()> { state.rebuild_modifier_tiles(&ids)?; } } - state - .start_render_loop(timestamp) - .map_err(|_| Error::RecoverableError("Error rendering".to_string()))?; + let frame_type = if flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8 { + state + .continue_render_loop(timestamp) + .map_err(|_| Error::RecoverableError("Error rendering".to_string()))? + } else { + state + .start_render_loop(timestamp) + .map_err(|_| Error::RecoverableError("Error rendering".to_string()))? + }; + return Ok(frame_type); }); - Ok(()) } #[no_mangle] @@ -179,7 +177,7 @@ pub extern "C" fn render_from_cache(_: i32) -> Result<()> { with_state!(state, { // 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 + // `continue_render_loop` 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 @@ -239,16 +237,6 @@ pub extern "C" fn render_loading_overlay() -> Result<()> { Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { - let result = with_state!(state, { state.process_animation_frame(timestamp) }); - if let Err(err) = result { - eprintln!("process_animation_frame error: {}", err); - } - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn reset_canvas() -> Result<()> { @@ -301,12 +289,7 @@ pub extern "C" fn set_view_end() -> Result<()> { performance::begin_measure!("set_view_end"); let render_state = get_render_state(); render_state.options.set_fast_mode(false); - render_state.cancel_animation_frame(); - - let scale = render_state.get_scale(); - render_state - .tile_viewbox - .update(render_state.viewbox, scale); + render_state.tile_viewbox.update(&render_state.viewbox); if render_state.options.is_profile_rebuild_tiles() { state.rebuild_tiles(); @@ -358,7 +341,6 @@ pub extern "C" fn set_modifiers_end() -> Result<()> { let render_state = get_render_state(); render_state.options.set_fast_mode(false); render_state.options.set_interactive_transform(false); - render_state.cancel_animation_frame(); performance::end_measure!("set_modifiers_end"); Ok(()) } @@ -946,7 +928,9 @@ pub fn free_gpu_resources() { get_render_state().free_gpu_resources(); } -fn main() { - #[cfg(target_arch = "wasm32")] - init_gl!(); +pub fn main() { + // Why an empty main? + // Right now with the target `wasm32-unknown-emscripten` it is not possible + // to compile a rust project without a main and generate the necessary glue + // code. So the only option is to compile it with an empty main. } diff --git a/render-wasm/src/math.rs b/render-wasm/src/math.rs index de819a86bb..0b8feeed04 100644 --- a/render-wasm/src/math.rs +++ b/render-wasm/src/math.rs @@ -6,6 +6,7 @@ pub type Rect = skia::Rect; pub type Matrix = skia::Matrix; pub type Vector = skia::Vector; pub type Point = skia::Point; +pub type Size = skia::Size; const THRESHOLD: f32 = 0.001; diff --git a/render-wasm/src/performance.rs b/render-wasm/src/performance.rs index 6f9eb233de..c27cd0efa7 100644 --- a/render-wasm/src/performance.rs +++ b/render-wasm/src/performance.rs @@ -1,10 +1,8 @@ -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn get_time() -> i32 { crate::get_now!() as i32 } -#[allow(dead_code)] #[cfg(not(target_arch = "wasm32"))] pub fn get_time() -> i32 { let now = std::time::Instant::now(); diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 6841caaa02..057287f5e9 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -38,6 +38,21 @@ pub use images::*; type ClipStack = Vec<(Rect, Option, Matrix)>; +#[repr(u8)] +pub enum FrameType { + None = 0, + Partial = 1, + Full = 2, +} + +#[allow(dead_code)] +#[repr(u8)] +pub enum RenderFlag { + None = 0, + Partial = 1, + Full = 2, +} + #[derive(Debug)] pub struct NodeRenderState { pub id: Uuid, @@ -334,10 +349,6 @@ pub(crate) struct RenderState { pub cached_viewbox: Viewbox, pub images: ImageStore, pub background_color: skia::Color, - // Identifier of the current requestAnimationFrame call, if any. - pub render_request_id: Option, - // Indicates whether the rendering process has pending frames. - pub render_in_progress: bool, // Stack of nodes pending to be rendered. pending_nodes: Vec, pub current_tile: Option, @@ -370,7 +381,7 @@ pub(crate) struct RenderState { /// Cleared at the beginning of a render pass; set to true after we clear Cache the first /// time we are about to blit a tile into Cache for this pass. pub cache_cleared_this_render: bool, - /// True iff the current tile had shapes assigned to it when we + /// True if the current tile had shapes assigned to it when we /// started rendering it. Lets us distinguish a genuinely empty /// tile (skip composite, just clear) from a tile whose walker /// finished its work in a previous PAF and is now being resumed @@ -401,7 +412,7 @@ pub struct InteractiveDragCrop { /// 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 [`Surfaces::max_texture_dimension_px`] (same budget as the atlas). +/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas). #[allow(clippy::too_many_arguments)] fn drag_crop_snapshot_window_px( max_side_px: i32, @@ -441,22 +452,6 @@ fn drag_crop_snapshot_window_px( (ox, oy, win_w, win_h) } -pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { - // First we retrieve the extended area of the viewport that we could render. - let TileRect(isx, isy, iex, iey) = - tiles::get_tiles_for_viewbox_with_interest(viewbox, interest, scale); - - let dx = if isx.signum() != iex.signum() { 1 } else { 0 }; - let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; - - let tile_size = tiles::TILE_SIZE; - ( - ((iex - isx).abs() + dx) * tile_size as i32, - ((iey - isy).abs() + dy) * tile_size as i32, - ) - .into() -} - impl RenderState { /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an /// interactive transform (drag/resize/rotate). @@ -554,8 +549,6 @@ impl RenderState { cached_viewbox: Viewbox::new(0., 0.), images: ImageStore::new(), background_color: skia::Color::TRANSPARENT, - render_request_id: None, - render_in_progress: false, pending_nodes: vec![], current_tile: None, sampling_options, @@ -563,9 +556,8 @@ impl RenderState { render_area_with_margins: Rect::new_empty(), tiles, tile_viewbox: tiles::TileViewbox::new_with_interest( - viewbox, + &viewbox, options.dpr_viewport_interest_area_threshold, - 1.0, ), pending_tiles: PendingTiles::new(), nested_fills: vec![], @@ -787,10 +779,12 @@ impl RenderState { self.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); self.resize( - self.viewbox.width.floor() as i32, - self.viewbox.height.floor() as i32, + self.viewbox.width().floor() as i32, + self.viewbox.height().floor() as i32, )?; self.fonts.set_scale_debug_font(dpr); + self.viewbox.set_dpr(dpr); + self.surfaces.set_dpr(dpr); } Ok(()) } @@ -836,7 +830,7 @@ impl RenderState { let dpr_height = (height as f32 * self.options.dpr).floor() as i32; self.surfaces.resize(dpr_width, dpr_height)?; self.viewbox.set_wh(width as f32, height as f32); - self.tile_viewbox.update(self.viewbox, self.get_scale()); + self.tile_viewbox.update(&self.viewbox); Ok(()) } @@ -901,23 +895,13 @@ impl RenderState { self.flush_and_submit(); } - #[allow(dead_code)] - pub fn get_canvas_at(&mut self, surface_id: SurfaceId) -> &skia::Canvas { - self.surfaces.canvas(surface_id) - } - - #[allow(dead_code)] - pub fn restore_canvas(&mut self, surface_id: SurfaceId) { - self.surfaces.canvas(surface_id).restore(); - } - - pub fn apply_render_to_final_canvas(&mut self, rect: skia::Rect) -> Result<()> { + pub fn apply_render_to_final_canvas(&mut self) -> Result<()> { // During interactive transforms we render tiles directly into Target; updating the cache // (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()?; - self.surfaces.draw_current_tile_direct( + self.surfaces.draw_current_tile_into_backbuffer( &tile_rect, self.background_color, surfaces::DrawOnCache::No, @@ -942,7 +926,7 @@ impl RenderState { .as_ref() .ok_or(Error::CriticalError("Current tile not found".to_string()))?; - self.surfaces.cache_current_tile_texture( + self.surfaces.draw_current_tile_into_tile_atlas( &self.tile_viewbox, ¤t_tile, &tile_rect, @@ -950,12 +934,15 @@ impl RenderState { self.render_area, ); + let rect = self.get_current_tile_bounds()?; self.surfaces - .draw_cached_tile_surface(current_tile, rect, self.background_color); + .draw_cached_tile_into_backbuffer(current_tile, &rect); + Ok(()) } - pub fn apply_drawing_to_render_canvas(&mut self, shape: Option<&Shape>, target: SurfaceId) { + /// This function draws the "surface stack" into the specified "target" surface. + pub fn draw_shape_surface_stack_into(&mut self, shape: Option<&Shape>, target: SurfaceId) { performance::begin_measure!("apply_drawing_to_render_canvas"); let paint = skia::Paint::default(); @@ -1663,7 +1650,7 @@ impl RenderState { } if apply_to_current_surface { - self.apply_drawing_to_render_canvas(Some(&shape), target_surface); + self.draw_shape_surface_stack_into(Some(&shape), target_surface); } // Only restore if we saved (optimization for simple shapes) @@ -1691,14 +1678,6 @@ impl RenderState { self.surfaces.update_render_context(self.render_area, scale); } - pub fn cancel_animation_frame(&mut self) { - if self.render_in_progress { - if let Some(frame_id) = self.render_request_id { - wapi::cancel_animation_frame!(frame_id); - } - } - } - fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { self.backbuffer_crop_cache.clear(); @@ -1778,12 +1757,12 @@ impl RenderState { 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 = self.surfaces.max_texture_dimension_px(); + let max_snap_px = get_gpu_state().max_texture_size(); // 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(); + 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. @@ -1901,16 +1880,14 @@ 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 cached_scale = self.get_cached_scale(); - let bg_color = self.background_color; // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. - if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { + if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() { self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color); + .draw_atlas_to_backbuffer(self.viewbox, bg_color); self.present_frame(shapes); performance::end_measure!("render_from_cache"); @@ -1925,11 +1902,7 @@ impl RenderState { let interest = self.options.dpr_viewport_interest_area_threshold; let TileRect(start_tile_x, start_tile_y, _, _) = - tiles::get_tiles_for_viewbox_with_interest( - self.cached_viewbox, - interest, - cached_scale, - ); + tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest); 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; let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; @@ -1944,8 +1917,8 @@ impl RenderState { let cache_h = cache_dim.height as f32; // Viewport in target pixels. - let vw = (self.viewbox.width * self.options.dpr).max(1.0); - let vh = (self.viewbox.height * self.options.dpr).max(1.0); + let vw = self.viewbox.dpr_width().max(1.0); + let vh = self.viewbox.dpr_height().max(1.0); // Inverse-map viewport corners into cache coordinates. // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). @@ -1956,8 +1929,11 @@ impl RenderState { 0.0 }; - let cx0 = (0.0 * inv) - translate_x; - let cy0 = (0.0 * inv) - translate_y; + // let cx0 = (0.0 * inv) - translate_x; + // let cy0 = (0.0 * inv) - translate_y; + // NOTA: 0.0 * inv => siempre 0 + let cx0 = -translate_x; + let cy0 = -translate_y; let cx1 = (vw * inv) - translate_x; let cy1 = (vh * inv) - translate_y; @@ -1970,12 +1946,9 @@ impl RenderState { min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; if !cache_covers { // Early return only if atlas exists; otherwise keep cache path. - if self.surfaces.has_atlas() { - self.surfaces.draw_atlas_to_backbuffer( - self.viewbox, - self.options.dpr, - bg_color, - ); + if !self.surfaces.atlas.is_empty() { + self.surfaces + .draw_atlas_to_backbuffer(self.viewbox, bg_color); self.present_frame(shapes); performance::end_measure!("render_from_cache"); @@ -1985,21 +1958,9 @@ impl RenderState { } } - // Setup canvas transform - { - let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); - canvas.save(); - canvas.scale((navigate_zoom, navigate_zoom)); - canvas.translate((translate_x, translate_y)); - canvas.clear(bg_color); - } - // Draw directly from cache surface, avoiding snapshot overhead self.surfaces.draw_cache_to_backbuffer(); - // Restore canvas state - self.surfaces.canvas(SurfaceId::Backbuffer).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 @@ -2008,23 +1969,14 @@ impl RenderState { // 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; - + let visible_rect = tiles::get_tiles_for_viewbox(&self.viewbox); + let offset = self.viewbox.get_offset(); 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); + let rect = tile.get_rect_with_offset(&offset); + self.surfaces.draw_cached_tile_into_backbuffer(tile, &rect); } } } @@ -2063,20 +2015,43 @@ impl RenderState { Ok(()) } + /// Clears all the necessary vecs and hashmaps. + /// Also garbage collects surfaces. + fn clear(&mut self, tree: ShapesPoolRef) { + #[cfg(feature = "stats")] + self.stats.clear(); + + self.surfaces.gc(); + + self.pending_nodes.clear(); + if self.pending_nodes.capacity() < tree.len() { + self.pending_nodes + .reserve(tree.len() - self.pending_nodes.capacity()); + } + + // Clear nested state stacks to avoid residual fills/blurs from previous renders + // being incorrectly applied to new frames + self.nested_fills.clear(); + self.nested_blurs.clear(); + self.nested_shadows.clear(); + + // reorder by distance to the center. + self.current_tile = None; + } + pub fn start_render_loop( &mut self, base_object: Option<&Uuid>, tree: ShapesPoolRef, timestamp: i32, sync_render: bool, - ) -> Result<()> { - #[cfg(feature = "stats")] - self.stats.clear(); + ) -> Result { + self.clear(tree); let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); - self.tile_viewbox.update(self.viewbox, scale); + self.tile_viewbox.update(&self.viewbox); self.focus_mode.reset(); performance::begin_measure!("render"); @@ -2086,7 +2061,7 @@ impl RenderState { // to clamp atlas updates. This prevents zoom-out tiles from forcing atlas // growth far beyond real content. let doc_bounds = self.compute_document_bounds(base_object, tree); - self.surfaces.set_atlas_doc_bounds(doc_bounds); + self.surfaces.atlas.set_doc_bounds(doc_bounds); self.cache_cleared_this_render = false; if self.options.is_interactive_transform() { @@ -2109,63 +2084,44 @@ impl RenderState { | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 | SurfaceId::TextDropShadows as u32; + self.surfaces.apply_mut(surface_ids, |s| { s.canvas().scale((scale, scale)); }); - let viewbox_cache_size = get_cache_size( - self.viewbox, - scale, + self.surfaces.resize_cache_from_viewbox( + &self.viewbox, + &self.cached_viewbox, self.options.dpr_viewport_interest_area_threshold, - ); - let cached_viewbox_cache_size = get_cache_size( - self.cached_viewbox, - scale, - self.options.dpr_viewport_interest_area_threshold, - ); - // Only resize cache if the new size is larger than the cached size - // This avoids unnecessary surface recreations when the cache size decreases - if viewbox_cache_size.width > cached_viewbox_cache_size.width - || viewbox_cache_size.height > cached_viewbox_cache_size.height - { - self.surfaces.resize_cache( - viewbox_cache_size, - self.options.dpr_viewport_interest_area_threshold, - )?; - } + )?; // FIXME - review debug // debug::render_debug_tiles_for_viewbox(self); let _tile_start = performance::begin_timed_log!("tile_cache_update"); + performance::begin_measure!("tile_cache"); let only_visible = self.options.is_interactive_transform(); self.pending_tiles .update(&self.tile_viewbox, &self.surfaces, only_visible); performance::end_measure!("tile_cache"); + performance::end_timed_log!("tile_cache_update", _tile_start); - self.pending_nodes.clear(); - if self.pending_nodes.capacity() < tree.len() { - self.pending_nodes - .reserve(tree.len() - self.pending_nodes.capacity()); - } - // Clear nested state stacks to avoid residual fills/blurs from previous renders - // being incorrectly applied to new frames - self.nested_fills.clear(); - self.nested_blurs.clear(); - self.nested_shadows.clear(); - // reorder by distance to the center. - self.current_tile = None; - - self.render_in_progress = true; - - self.apply_drawing_to_render_canvas(None, SurfaceId::Current); + self.draw_shape_surface_stack_into(None, SurfaceId::Current); + #[allow(unused)] + let mut frame_type = FrameType::None; if sync_render { - self.render_shape_tree_sync(base_object, tree, timestamp)?; + frame_type = self.render_shape_tree_sync(base_object, tree, timestamp)?; } else { - self.process_animation_frame(base_object, tree, timestamp)?; + frame_type = self.continue_render_loop(base_object, tree, timestamp)?; + + // This is an option to debug frames. + if self.options.capture_frames > 0 { + self.options.capture_frames -= 1; + } + // 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 @@ -2180,7 +2136,7 @@ impl RenderState { performance::end_measure!("start_render_loop"); performance::end_timed_log!("start_render_loop", _start); - Ok(()) + Ok(frame_type) } fn compute_document_bounds( @@ -2214,37 +2170,45 @@ impl RenderState { acc } - pub fn process_animation_frame( + pub fn continue_render_loop( &mut self, base_object: Option<&Uuid>, tree: ShapesPoolRef, timestamp: i32, - ) -> Result<()> { - performance::begin_measure!("process_animation_frame"); - self.render_shape_tree_partial(base_object, tree, timestamp, true)?; + ) -> Result { + performance::begin_measure!("continue_render_loop"); + let frame_type = self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - if self.render_in_progress { - // Partial frame: just flush GPU work. The display shows the last - // fully submitted frame; no need to copy or draw UI overlays here. - self.flush(); - self.cancel_animation_frame(); - self.render_request_id = Some(wapi::request_animation_frame!()); - } else { - // 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() { - 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"); + if !self.options.is_interactive_transform() { + self.surfaces + .draw_tile_atlas_to_backbuffer(&self.viewbox, &self.tile_viewbox); } - performance::end_measure!("process_animation_frame"); - Ok(()) + match frame_type { + FrameType::None => { + panic!("FrameType::None"); + } + FrameType::Partial => { + // Partial frame: just flush GPU work. The display shows the last + // fully submitted frame; no need to copy or draw UI overlays here. + self.flush(); + } + 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() { + 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"); + } + } + performance::end_measure!("continue_render_loop"); + Ok(frame_type) } pub fn render_shape_tree_sync( @@ -2252,10 +2216,10 @@ impl RenderState { base_object: Option<&Uuid>, tree: ShapesPoolRef, timestamp: i32, - ) -> Result<()> { + ) -> Result { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; self.present_frame(tree); - Ok(()) + Ok(FrameType::Full) } pub fn render_shape_pixels( @@ -2560,18 +2524,11 @@ impl RenderState { } pub fn get_current_tile_bounds(&mut self) -> Result { - let tiles::Tile(tile_x, tile_y) = self + let tile = self .current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?; - let scale = self.get_scale(); - let offset_x = self.viewbox.area.left * scale; - let offset_y = self.viewbox.area.top * scale; - Ok(Rect::from_xywh( - (tile_x as f32 * tiles::TILE_SIZE) - offset_x, - (tile_y as f32 * tiles::TILE_SIZE) - offset_y, - tiles::TILE_SIZE, - tiles::TILE_SIZE, - )) + let offset = self.viewbox.get_offset(); + Ok(tile.get_rect_with_offset(&offset)) } pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect { @@ -3337,7 +3294,7 @@ impl RenderState { .canvas(SurfaceId::DropShadows) .clear(skia::Color::TRANSPARENT); } else if visited_children { - self.apply_drawing_to_render_canvas(Some(element), target_surface); + self.draw_shape_surface_stack_into(Some(element), target_surface); } // Skip nested state updates for flattened containers @@ -3406,6 +3363,7 @@ impl RenderState { } iteration += 1; } + Ok((is_empty, false)) } @@ -3415,7 +3373,7 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, allow_stop: bool, - ) -> Result<()> { + ) -> Result { let mut should_stop = false; let root_ids = { if let Some(shape_id) = base_object { @@ -3430,53 +3388,20 @@ impl RenderState { while !should_stop { if let Some(current_tile) = self.current_tile { - if self.surfaces.has_cached_tile_surface(current_tile) { - performance::begin_measure!("render_shape_tree::cached"); - // During interactive transforms, `Target` is preserved and seeded once - // from Backbuffer. Cached tiles are therefore already visible and - // re-blitting them costs extra GPU work. - let tile_rect = self.get_current_tile_bounds()?; - if !self.options.is_interactive_transform() { - self.surfaces.draw_cached_tile_surface( - current_tile, - 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() { - debug::render_workspace_current_tile( - self, - "Cached".to_string(), - current_tile, - tile_rect, - ); - } - } else { + // NOTE: For now we don't need to cover the case where the tile + // is not cached because everything will be handled from draw_atlas. + if !self.surfaces.has_cached_tile_surface(current_tile) { performance::begin_measure!("render_shape_tree::uncached"); let (is_empty, early_return) = self .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; + #[cfg(target_arch = "wasm32")] + if self.options.capture_frames > 0 { + debug::console_debug_surface(self, SurfaceId::Backbuffer); + } + if early_return { - return Ok(()); + return Ok(FrameType::Partial); } performance::end_measure!("render_shape_tree::uncached"); @@ -3489,13 +3414,13 @@ impl RenderState { if self.options.is_interactive_transform() { // During drag, avoid snapshot-based caching. Draw Current directly // into Target (and Cache) to reduce stalls. - self.surfaces.draw_current_tile_direct( + self.surfaces.draw_current_tile_into_backbuffer( &tile_rect, self.background_color, surfaces::DrawOnCache::Yes, ); } else { - self.apply_render_to_final_canvas(tile_rect)?; + self.apply_render_to_final_canvas()?; } if self.options.is_debug_visible() { @@ -3506,10 +3431,9 @@ impl RenderState { tile_rect, ); } - } else { - // Tile is uncached and has no shapes to render - self.apply_render_to_final_canvas(tile_rect)?; } + } else if self.tiles.is_empty_at(current_tile) { + self.surfaces.remove_cached_tile_surface(current_tile); } } @@ -3527,62 +3451,65 @@ impl RenderState { // empty tile. self.current_tile_had_shapes = false; - if !self.surfaces.has_cached_tile_surface(next_tile) { - if let Some(ids) = self.tiles.get_shapes_at(next_tile) { - // Check if any shape on this tile has a background blur. - // If so, we need ALL root shapes rendered (not just those - // assigned to this tile) because the blur snapshots Current - // which must contain the shapes behind it. - let tile_has_bg_blur = ids.iter().any(|id| { - tree.get(id).is_some_and(|s| { - s.blur.is_some_and(|b| { - !b.hidden && b.blur_type == BlurType::BackgroundBlur - }) - }) - }); + let Some(ids) = self.tiles.get_shapes_at(next_tile) else { + // If the tile is empty we do not need to render it. + continue; + }; - // We only need first level shapes, in the same order as the parent node. - // - // During interactive transforms we may invalidate only the modified shapes - // (to avoid massive ancestor eviction). However, we still composite full - // tiles (we clear the tile rect before drawing Current), so we must render - // all root shapes that can contribute to this tile; otherwise, unchanged - // siblings inside the same tile would disappear. - let mut valid_ids = Vec::with_capacity(ids.len()); - if self.options.is_interactive_transform() || tile_has_bg_blur { - valid_ids.extend(root_ids.iter().copied()); - } else { - for root_id in root_ids.iter() { - if ids.contains(root_id) { - valid_ids.push(*root_id); - } - } + if self.surfaces.has_cached_tile_surface(next_tile) { + // If the tile is cached, then we do not need to + // render it. + continue; + } + + // Check if any shape on this tile has a background blur. + // If so, we need ALL root shapes rendered (not just those + // assigned to this tile) because the blur snapshots Current + // which must contain the shapes behind it. + let tile_has_bg_blur = ids.iter().any(|id| { + tree.get(id).is_some_and(|s| { + s.blur + .is_some_and(|b| !b.hidden && b.blur_type == BlurType::BackgroundBlur) + }) + }); + + // We only need first level shapes, in the same order as the parent node. + // + // During interactive transforms we may invalidate only the modified shapes + // (to avoid massive ancestor eviction). However, we still composite full + // tiles (we clear the tile rect before drawing Current), so we must render + // all root shapes that can contribute to this tile; otherwise, unchanged + // siblings inside the same tile would disappear. + let mut valid_ids = Vec::with_capacity(ids.len()); + if self.options.is_interactive_transform() || tile_has_bg_blur { + valid_ids.extend(root_ids.iter().copied()); + } else { + for root_id in root_ids.iter() { + if ids.contains(root_id) { + valid_ids.push(*root_id); } - - if !valid_ids.is_empty() { - self.current_tile_had_shapes = true; - } - - self.pending_nodes.extend(valid_ids.into_iter().map(|id| { - NodeRenderState { - id, - visited_children: false, - clip_bounds: None, - visited_mask: false, - mask: false, - flattened: false, - } - })); } } + + if !valid_ids.is_empty() { + self.current_tile_had_shapes = true; + } + + self.pending_nodes + .extend(valid_ids.into_iter().map(|id| NodeRenderState { + id, + visited_children: false, + clip_bounds: None, + visited_mask: false, + mask: false, + flattened: false, + })); } else { + // If there are no more pending tiles, stop. should_stop = true; } } - self.render_in_progress = false; - self.surfaces.gc(); - // 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 @@ -3595,7 +3522,7 @@ impl RenderState { self.cached_viewbox = self.viewbox; } - Ok(()) + Ok(FrameType::Full) } /* @@ -3648,7 +3575,7 @@ impl RenderState { shape: &Shape, tree: ShapesPoolRef, ) -> HashSet { - let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree); + let tile_rect = self.get_tiles_for_shape(shape, tree); // Collect old tiles to avoid borrow conflict with remove_shape_at let old_tiles: Vec<_> = self @@ -3661,7 +3588,7 @@ impl RenderState { // When the shape has an active modifier (i.e. is being moved/resized), // clear its OLD doc-space extent from the atlas using the raw // (pre-modifier) shape. The per-tile clearing done later via - // `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects` + // `clear_tile_in_atlas` only covers tiles tracked in `atlas.tile_doc_rects` // at the current zoom level. However, the atlas may also contain stale // pixels from previous zoom levels (tiles are larger / smaller in doc // space at different zoom scales) that were never re-tracked after a zoom @@ -3673,7 +3600,9 @@ impl RenderState { if tree.get_modifier(&shape.id).is_some() { if let Some(raw_shape) = tree.get_raw(&shape.id) { let old_extrect = raw_shape.extrect(tree, 1.0); - self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect); + self.surfaces + .atlas + .clear_doc_rect_in_atlas_clipped(old_extrect); } } @@ -3684,7 +3613,7 @@ impl RenderState { } // Then, add the shape to the new tiles - for tile in (rsx..=rex).flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) { + for tile in tile_rect.iter(true) { self.tiles.add_shape_at(tile, shape.id); result.insert(tile); } @@ -3713,16 +3642,13 @@ impl RenderState { shape: &Shape, tree: ShapesPoolRef, ) -> Vec { - let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree); - + let tile_rect = self.get_tiles_for_shape(shape, tree); let old_tiles: HashSet = self .tiles .get_tiles_of(shape.id) .map_or(HashSet::new(), |tiles| tiles.iter().copied().collect()); - let new_tiles: HashSet = (rsx..=rex) - .flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) - .collect(); + let new_tiles: HashSet = tile_rect.iter(true).collect(); // Tiles where shape is being removed from index (left interest area) let removed: Vec<_> = old_tiles.difference(&new_tiles).copied().collect(); @@ -3747,18 +3673,16 @@ impl RenderState { } /* - * Add the tiles forthe shape to the index. + * Add the tiles for the shape to the index. * returns the tiles that have been updated */ pub fn add_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec { - let TileRect(rsx, rsy, rex, rey) = self.get_tiles_for_shape(shape, tree); - let tiles: Vec<_> = (rsx..=rex) - .flat_map(|x| (rsy..=rey).map(move |y| tiles::Tile::from(x, y))) - .collect(); - + performance::begin_measure!("add_shape_tiles"); + let tiles: Vec = self.get_tiles_for_shape(shape, tree).iter(true).collect(); for tile in tiles.iter() { self.tiles.add_shape_at(*tile, shape.id); } + performance::end_measure!("add_shape_tiles"); tiles } @@ -3771,8 +3695,9 @@ impl RenderState { /// survive so that fast-mode renders during pan still show shadows/blur. pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { let zoom_changed = self.zoom_changed(); - - let mut nodes = vec![Uuid::nil()]; + performance::begin_measure!("rebuild_tile_index"); + let mut nodes = Vec::::with_capacity(64); + nodes.push(Uuid::nil()); while let Some(shape_id) = nodes.pop() { if let Some(shape) = tree.get(&shape_id) { if shape_id != Uuid::nil() { @@ -3789,6 +3714,7 @@ impl RenderState { } } } + performance::end_measure!("rebuild_tile_index"); } pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { @@ -3927,11 +3853,7 @@ impl RenderState { if let Some((_, export_scale)) = self.export_context { return export_scale; } - self.viewbox.zoom() * self.options.dpr - } - - pub fn get_cached_scale(&self) -> f32 { - self.cached_viewbox.zoom() * self.options.dpr + self.viewbox.get_scale() } pub fn zoom_changed(&self) -> bool { diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 42dd3bd25a..c69e22514a 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] use super::{tiles, RenderState, SurfaceId}; #[cfg(target_arch = "wasm32")] @@ -22,7 +23,6 @@ fn get_debug_rect(rect: Rect) -> Rect { ) } -#[allow(dead_code)] fn render_debug_view(render_state: &mut RenderState) { let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); @@ -36,7 +36,6 @@ fn render_debug_view(render_state: &mut RenderState) { .draw_rect(rect, &paint); } -#[allow(dead_code)] pub fn render_debug_cache_surface(render_state: &mut RenderState) { let canvas = render_state.surfaces.canvas(SurfaceId::Debug); canvas.save(); @@ -78,7 +77,6 @@ pub fn render_wasm_label(render_state: &mut RenderState) { } } -#[allow(dead_code)] pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) { let tiles::TileRect(sx, sy, ex, ey) = render_state.tile_viewbox.interest_rect; let canvas = render_state.surfaces.canvas(SurfaceId::Debug); @@ -91,7 +89,6 @@ pub fn render_debug_tiles_for_viewbox(render_state: &mut RenderState) { } // Renders the tiles in the viewbox -#[allow(dead_code)] pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { let scale = render_state.get_scale(); let canvas = render_state.surfaces.canvas(SurfaceId::Debug); @@ -101,30 +98,25 @@ pub fn render_debug_viewbox_tiles(render_state: &mut RenderState) { paint.set_stroke_width(1.); let tile_size = tiles::get_tile_size(scale); - let tiles::TileRect(sx, sy, ex, ey) = - tiles::get_tiles_for_rect(render_state.viewbox.area, tile_size); + let tile_rect = tiles::get_tiles_for_rect(render_state.viewbox.area, tile_size); + let tiles::TileRect(sx, sy, ex, ey) = tile_rect; + let str_rect = format!("{} {} {} {}", sx, sy, ex, ey); let debug_font = render_state.fonts.debug_font(); canvas.draw_str(str_rect, skia::Point::new(100.0, 100.0), debug_font, &paint); let tile_size = tiles::get_tile_size(scale); - for y in sy..=ey { - for x in sx..=ex { - let rect = Rect::from_xywh( - x as f32 * tile_size, - y as f32 * tile_size, - tile_size, - tile_size, - ); - let debug_rect = get_debug_rect(rect); - let p = skia::Point::new(debug_rect.x(), debug_rect.y() - 1.); - let str = format!("{}:{}", x, y); - let debug_font = render_state.fonts.debug_font(); - paint.set_style(skia::PaintStyle::Fill); - canvas.draw_str(str, p, debug_font, &paint); - canvas.draw_rect(debug_rect, &paint); - } + for tile in tile_rect.iter(true) { + let tiles::Tile(x, y) = tile; + let rect = tile.get_rect_with_size(tile_size); + let debug_rect = get_debug_rect(rect); + let p = skia::Point::new(debug_rect.x(), debug_rect.y() - 1.); + let str = format!("{}:{}", x, y); + let debug_font = render_state.fonts.debug_font(); + paint.set_style(skia::PaintStyle::Fill); + canvas.draw_str(str, p, debug_font, &paint); + canvas.draw_rect(debug_rect, &paint); } } @@ -187,13 +179,11 @@ pub fn render_debug_shape( } } -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn trap() { run_script!("debugger"); } -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] #[derive(Debug, PartialEq)] pub enum SurfaceBackendKind { @@ -203,7 +193,6 @@ pub enum SurfaceBackendKind { Unknown, } -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKind { if skia::gpu::surfaces::get_backend_texture( @@ -231,7 +220,6 @@ pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKi SurfaceBackendKind::Unknown } -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state @@ -242,7 +230,6 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state @@ -253,7 +240,6 @@ pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceI println!("{}", base64_image); } -#[allow(dead_code)] #[cfg(target_arch = "wasm32")] pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, rect: skia::Rect) { let int_rect = skia::IRect::from_ltrb( @@ -273,6 +259,16 @@ pub fn console_debug_surface_rect(render_state: &mut RenderState, id: SurfaceId, } } +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn capture_frames(capture_frames: i32) -> Result<()> { + get_render_state() + .options + .set_capture_frames(capture_frames); + Ok(()) +} + #[no_mangle] #[wasm_error] #[cfg(target_arch = "wasm32")] diff --git a/render-wasm/src/render/gpu_state.rs b/render-wasm/src/render/gpu_state.rs index 07ce266eff..e934dd4f34 100644 --- a/render-wasm/src/render/gpu_state.rs +++ b/render-wasm/src/render/gpu_state.rs @@ -5,6 +5,9 @@ use skia_safe::gpu::{ }; use skia_safe::{self as skia, ISize}; +const MIN_MAX_TEXTURE_SIZE: i32 = 512; +const MAX_MAX_TEXTURE_SIZE: i32 = 8192 * 2; + #[derive(Debug, Clone)] pub struct GpuState { pub context: DirectContext, @@ -28,6 +31,7 @@ impl GpuState { let context = gpu::direct_contexts::make_gl(interface, Some(&context_options)).ok_or( Error::CriticalError("Failed to create GL context".to_string()), )?; + let framebuffer_info = { let mut fboid: gl::types::GLint = 0; unsafe { gl::GetIntegerv(gl::FRAMEBUFFER_BINDING, &mut fboid) }; @@ -47,6 +51,12 @@ impl GpuState { }) } + pub fn max_texture_size(&self) -> i32 { + self.context + .max_texture_size() + .clamp(MIN_MAX_TEXTURE_SIZE, MAX_MAX_TEXTURE_SIZE) + } + fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { unsafe { gl::DeleteTextures(1, &texture_id); diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 45a2b911d1..7a6a3a3d39 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -30,6 +30,7 @@ pub struct RenderOptions { pub max_blocking_time_ms: i32, pub node_batch_threshold: i32, pub blur_downscale_threshold: f32, + pub capture_frames: i32, } impl Default for RenderOptions { @@ -45,6 +46,7 @@ impl Default for RenderOptions { max_blocking_time_ms: MAX_BLOCKING_TIME_MS, node_batch_threshold: NODE_BATCH_THRESHOLD, blur_downscale_threshold: BLUR_DOWNSCALE_THRESHOLD, + capture_frames: 0, } } } @@ -67,6 +69,10 @@ impl RenderOptions { self.fast_mode = enabled; } + 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 diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 6e8736bffc..de33abb7e0 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -3,31 +3,43 @@ use crate::shapes::Shape; use crate::view::Viewbox; use crate::{get_gpu_state, performance}; -use skia_safe::{self as skia, IRect, Paint, RRect}; +use skia_safe::{self as skia, IRect, Paint, RRect, Rect}; -use super::{ - gpu_state::GpuState, - tiles::{self, Tile, TileViewbox, TILE_SIZE}, -}; +use super::{gpu_state::GpuState, tiles, tiles::Tile, tiles::TileRect, tiles::TileViewbox}; +use crate::math::Point; use base64::{engine::general_purpose, Engine as _}; use std::collections::{HashMap, HashSet}; const TEXTURES_CACHE_CAPACITY: usize = 1024; const TEXTURES_BATCH_DELETE: usize = 256; + // This is the amount of extra space we're going to give to all the surfaces to render shapes. // If it's too big it could affect performance. +const TILE_SIZE: i32 = tiles::TILE_SIZE as i32; const TILE_SIZE_MULTIPLIER: i32 = 2; +const TILE_MARGIN_SIZE: i32 = TILE_SIZE * TILE_SIZE_MULTIPLIER / 4; +const TILE_DRAWABLE_RECT: IRect = IRect { + left: TILE_MARGIN_SIZE, + top: TILE_MARGIN_SIZE, + right: TILE_MARGIN_SIZE + TILE_SIZE, + bottom: TILE_MARGIN_SIZE + TILE_SIZE, +}; -/// Atlas texture size limits (px per side). -/// -/// - `DEFAULT_MAX_ATLAS_TEXTURE_SIZE` is the startup fallback used until the -/// frontend reads the real `gl.MAX_TEXTURE_SIZE` and sends it via -/// [`Surfaces::set_max_atlas_texture_size`]. -/// - `MAX_ATLAS_TEXTURE_SIZE` is a hard upper bound to clamp the runtime value -/// (defensive cap to avoid accidentally creating oversized GPU textures). -const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; -const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; +pub fn get_cache_size(viewbox: &Viewbox, interest: i32) -> skia::ISize { + // First we retrieve the extended area of the viewport that we could render. + let TileRect(isx, isy, iex, iey) = + tiles::get_tiles_for_viewbox_with_interest(viewbox, interest); + + let dx = if isx.signum() != iex.signum() { 1 } else { 0 }; + let dy = if isy.signum() != iey.signum() { 1 } else { 0 }; + + ( + ((iex - isx).abs() + dx) * TILE_SIZE, + ((iey - isy).abs() + dy) * TILE_SIZE, + ) + .into() +} #[derive(Debug, PartialEq)] pub enum DrawOnCache { @@ -37,6 +49,7 @@ pub enum DrawOnCache { #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] +#[allow(unused)] pub enum SurfaceId { Target = 0b000_0000_0001, Filter = 0b000_0000_0010, @@ -52,6 +65,332 @@ pub enum SurfaceId { Debug = 0b100_0000_0001, Atlas = 0b100_0000_0010, Backbuffer = 0b100_0000_0100, + TileAtlas = 0b100_0000_1000, +} + +pub struct DocAtlas { + // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. + // It grows dynamically to include any rendered document rect. + pub surface: skia::Surface, + pub origin: skia::Point, + pub size: skia::ISize, + /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px). + /// When the atlas would exceed `max_texture_size`, this value is + /// reduced so the atlas stays within the fixed texture cap. + pub scale: f32, + /// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to + /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects. + pub doc_bounds: Option, + /// Tracks the last document-space rect written to the atlas per tile. + /// Used to clear old content without clearing the whole (potentially huge) tile rect. + pub tile_doc_rects: HashMap, +} + +impl DocAtlas { + pub fn try_new() -> Result { + // Keep atlas as a regular surface like the rest. Start with a tiny + // transparent surface and grow it on demand. + let mut surface = + get_gpu_state().create_surface_with_dimensions("atlas".to_string(), 1, 1)?; + + surface.canvas().clear(skia::Color::TRANSPARENT); + + Ok(Self { + surface, + origin: skia::Point::new(0.0, 0.0), + size: skia::ISize::new(0, 0), + scale: 1.0, + doc_bounds: None, + tile_doc_rects: HashMap::default(), + }) + } + + pub fn is_empty(&self) -> bool { + self.size.width <= 0 || self.size.height <= 0 + } + + /// Sets the document-space bounds used to clamp atlas updates. + /// Pass `None` to disable clamping. + pub fn set_doc_bounds(&mut self, bounds: Option) { + self.doc_bounds = bounds.filter(|b| !b.is_empty()); + } + + fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect { + if doc_rect.is_empty() { + return doc_rect; + } + if let Some(bounds) = self.doc_bounds { + let mut r = doc_rect; + if r.intersect(bounds) { + r + } else { + skia::Rect::new_empty() + } + } else { + doc_rect + } + } + + fn ensure_atlas_contains( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + if doc_rect.is_empty() { + return Ok(()); + } + + // Current atlas bounds in document space (1 unit == 1 px). + let current_left = self.origin.x; + let current_top = self.origin.y; + let scale = self.scale.max(0.01); + let current_right = current_left + (self.size.width as f32) / scale; + let current_bottom = current_top + (self.size.height as f32) / scale; + + let mut new_left = current_left; + let mut new_top = current_top; + let mut new_right = current_right; + let mut new_bottom = current_bottom; + + // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs). + let needs_init = self.size.width <= 0 || self.size.height <= 0; + if needs_init { + new_left = doc_rect.left.floor(); + new_top = doc_rect.top.floor(); + new_right = doc_rect.right.ceil(); + new_bottom = doc_rect.bottom.ceil(); + } else { + new_left = new_left.min(doc_rect.left.floor()); + new_top = new_top.min(doc_rect.top.floor()); + new_right = new_right.max(doc_rect.right.ceil()); + new_bottom = new_bottom.max(doc_rect.bottom.ceil()); + } + + // Add padding to reduce realloc frequency. + let pad = tiles::TILE_SIZE; + new_left -= pad; + new_top -= pad; + new_right += pad; + new_bottom += pad; + + let doc_w = (new_right - new_left).max(1.0); + let doc_h = (new_bottom - new_top).max(1.0); + + // Compute atlas scale needed to fit within the fixed texture cap. + // Keep the highest possible scale (closest to 1.0) that still fits. + let cap = gpu_state.max_texture_size().max(TILE_SIZE) as f32; + let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); + + // Never upscale the atlas (it would add blur and churn). + let new_scale = self.scale.min(required_scale).max(0.01); + + let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32; + let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32; + + // Fast path: existing atlas already contains the rect. + if !needs_init + && doc_rect.left >= current_left + && doc_rect.top >= current_top + && doc_rect.right <= current_right + && doc_rect.bottom <= current_bottom + { + return Ok(()); + } + + let mut new_surface = + gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; + new_surface.canvas().clear(skia::Color::TRANSPARENT); + + // Copy old atlas into the new one with offset. + if !needs_init { + let old_scale = self.scale.max(0.01); + let scale_ratio = new_scale / old_scale; + let dx = (current_left - new_left) * new_scale; + let dy = (current_top - new_top) * new_scale; + + let image = self.surface.image_snapshot(); + let src = + skia::Rect::from_xywh(0.0, 0.0, self.size.width as f32, self.size.height as f32); + let dst = skia::Rect::from_xywh( + dx, + dy, + (self.size.width as f32) * scale_ratio, + (self.size.height as f32) * scale_ratio, + ); + new_surface.canvas().draw_image_rect( + &image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); + } + + self.origin = skia::Point::new(new_left, new_top); + self.size = skia::ISize::new(new_w, new_h); + self.scale = new_scale; + gpu_state.delete_surface(&mut self.surface); + self.surface = new_surface; + Ok(()) + } + + fn blit_tile_image_into_atlas( + &mut self, + gpu_state: &mut GpuState, + tile_image: &skia::Image, + tile_doc_rect: skia::Rect, + ) -> Result<()> { + if tile_doc_rect.is_empty() { + return Ok(()); + } + + // Clamp to document bounds (if any) and compute a matching source-rect in tile pixels. + let mut clipped_doc_rect = tile_doc_rect; + if let Some(bounds) = self.doc_bounds { + if !clipped_doc_rect.intersect(bounds) { + return Ok(()); + } + } + if clipped_doc_rect.is_empty() { + return Ok(()); + } + + self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (clipped_doc_rect.left - self.origin.x) * self.scale, + (clipped_doc_rect.top - self.origin.y) * self.scale, + clipped_doc_rect.width() * self.scale, + clipped_doc_rect.height() * self.scale, + ); + + // Compute source rect in tile_image pixel coordinates. + let img_w = tile_image.width() as f32; + let img_h = tile_image.height() as f32; + let tw = tile_doc_rect.width().max(1.0); + let th = tile_doc_rect.height().max(1.0); + + let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w; + let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h; + let sw = (clipped_doc_rect.width() / tw) * img_w; + let sh = (clipped_doc_rect.height() / th) * img_h; + let src = skia::Rect::from_xywh(sx, sy, sw, sh); + + self.surface.canvas().draw_image_rect( + tile_image, + Some((&src, skia::canvas::SrcRectConstraint::Fast)), + dst, + &skia::Paint::default(), + ); + Ok(()) + } + + /// Clears a doc-space rect from the atlas **without** growing it. + /// + /// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the + /// current atlas bounds and skips silently if there is no overlap. Use this + /// when evicting stale shape content (e.g. before a drag re-render) where + /// growing the atlas to accommodate an out-of-range rect would be wasteful. + pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) { + if self.is_empty() || doc_rect.is_empty() { + return; + } + + let scale = self.scale.max(0.01); + let atlas_doc_right = self.origin.x + (self.size.width as f32) / scale; + let atlas_doc_bottom = self.origin.y + (self.size.height as f32) / scale; + + // Intersect with current atlas bounds in doc space. + let mut clipped = doc_rect; + let atlas_bounds = skia::Rect::from_ltrb( + self.origin.x, + self.origin.y, + atlas_doc_right, + atlas_doc_bottom, + ); + if !clipped.intersect(atlas_bounds) { + return; + } + + // Apply doc_bounds clamping. + if let Some(bounds) = self.doc_bounds { + if !clipped.intersect(bounds) { + return; + } + } + + if clipped.is_empty() { + return; + } + + let dst = skia::Rect::from_xywh( + (clipped.left - self.origin.x) * scale, + (clipped.top - self.origin.y) * scale, + clipped.width() * scale, + clipped.height() * scale, + ); + + let canvas = self.surface.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + } + + pub fn clear_doc_rect_in_atlas( + &mut self, + gpu_state: &mut GpuState, + doc_rect: skia::Rect, + ) -> Result<()> { + let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect); + if doc_rect.is_empty() { + return Ok(()); + } + + self.ensure_atlas_contains(gpu_state, doc_rect)?; + + // Destination is document-space rect mapped into atlas pixel coords. + let dst = skia::Rect::from_xywh( + (doc_rect.left - self.origin.x) * self.scale, + (doc_rect.top - self.origin.y) * self.scale, + doc_rect.width() * self.scale, + doc_rect.height() * self.scale, + ); + + let canvas = self.surface.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + Ok(()) + } + + /// Clears the last atlas region written by `tile` (if any). + /// + /// This avoids clearing the entire logical tile rect which, at very low + /// zoom levels, can be enormous in document space and would unnecessarily + /// grow / rescale the atlas. + pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { + if let Some(doc_rect) = self.tile_doc_rects.remove(&tile) { + self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?; + } + Ok(()) + } + + /// Returns a snapshot of the atlas together with its scale and origin, so the + /// caller can take it **once** per `rebuild_backbuffer_crop_cachef` and share it + /// across all shapes that need the tile/atlas fallback path — avoiding an + /// `image_snapshot` (and potential GPU flush) per shape. + pub fn snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> { + if self.is_empty() { + return None; + } + Some(( + self.surface.image_snapshot(), + self.scale.max(0.01), + self.origin, + )) + } } pub struct Surfaces { @@ -79,32 +418,18 @@ pub struct Surfaces { export: skia::Surface, // Persistent viewport-sized surface used to keep the last presented frame. backbuffer: skia::Surface, + // Atlas used to keep tiles. + tile_atlas: skia::Surface, tiles: TileTextureCache, - // Persistent 1:1 document-space atlas that gets incrementally updated as tiles render. - // It grows dynamically to include any rendered document rect. - atlas: skia::Surface, - atlas_origin: skia::Point, - atlas_size: skia::ISize, - /// Atlas pixel density relative to document pixels (1.0 == 1:1 doc px). - /// When the atlas would exceed `max_atlas_texture_size`, this value is - /// reduced so the atlas stays within the fixed texture cap. - atlas_scale: f32, - /// Optional document-space bounds (1 unit == 1 doc px @ 100% zoom) used to - /// clamp atlas writes/clears so the atlas doesn't grow due to outlier tile rects. - atlas_doc_bounds: Option, - /// Max width/height in pixels for the atlas surface (typically browser - /// `MAX_TEXTURE_SIZE`). Set from ClojureScript after WebGL context creation. - max_atlas_texture_size: i32, + pub atlas: DocAtlas, sampling_options: skia::SamplingOptions, - /// Tracks the last document-space rect written to the atlas per tile. - /// Used to clear old content without clearing the whole (potentially huge) tile rect. - atlas_tile_doc_rects: HashMap, + atlas_sampling_options: skia::SamplingOptions, pub margins: skia::ISize, // Tracks which surfaces have content (dirty flag bitmask) dirty_surfaces: u32, - extra_tile_dims: skia::ISize, + dpr: f32, } #[allow(dead_code)] @@ -127,6 +452,14 @@ impl Surfaces { let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; let backbuffer = gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?; + + let max_texture_size = gpu_state.max_texture_size(); + let tile_atlas = gpu_state.create_surface_with_dimensions( + "tile_atlas".to_string(), + max_texture_size, + max_texture_size, + )?; + let current = gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?; @@ -144,12 +477,10 @@ impl Surfaces { let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; - // Keep atlas as a regular surface like the rest. Start with a tiny - // transparent surface and grow it on demand. - let mut atlas = gpu_state.create_surface_with_dimensions("atlas".to_string(), 1, 1)?; - atlas.canvas().clear(skia::Color::TRANSPARENT); - let tiles = TileTextureCache::new(); + // 512, why not? + let tiles = TileTextureCache::new(tile_atlas.width(), 512); + let atlas = DocAtlas::try_new()?; Ok(Self { target, filter, @@ -164,324 +495,49 @@ impl Surfaces { debug, export, backbuffer, + tile_atlas, tiles, atlas, - atlas_origin: skia::Point::new(0.0, 0.0), - atlas_size: skia::ISize::new(0, 0), - atlas_scale: 1.0, - atlas_doc_bounds: None, - max_atlas_texture_size: DEFAULT_MAX_ATLAS_TEXTURE_SIZE, sampling_options, - atlas_tile_doc_rects: HashMap::default(), + atlas_sampling_options: skia::SamplingOptions::new( + skia::FilterMode::Nearest, + skia::MipmapMode::None, + ), margins, dirty_surfaces: 0, extra_tile_dims, + dpr: 1.0, }) } - /// Sets the maximum atlas texture dimension (one side). Should match the - /// WebGL `MAX_TEXTURE_SIZE` reported by the browser. Values are clamped to - /// a small minimum so the atlas logic stays well-defined. - pub fn set_max_atlas_texture_size(&mut self, max_px: i32) { - self.max_atlas_texture_size = max_px.clamp(TILE_SIZE as i32, MAX_ATLAS_TEXTURE_SIZE); - } - - #[inline] - pub fn max_texture_dimension_px(&self) -> i32 { - self.max_atlas_texture_size - } - - /// Sets the document-space bounds used to clamp atlas updates. - /// Pass `None` to disable clamping. - pub fn set_atlas_doc_bounds(&mut self, bounds: Option) { - self.atlas_doc_bounds = bounds.filter(|b| !b.is_empty()); - } - - fn clamp_doc_rect_to_bounds(&self, doc_rect: skia::Rect) -> skia::Rect { - if doc_rect.is_empty() { - return doc_rect; - } - if let Some(bounds) = self.atlas_doc_bounds { - let mut r = doc_rect; - if r.intersect(bounds) { - r - } else { - skia::Rect::new_empty() - } - } else { - doc_rect - } - } - - fn ensure_atlas_contains( - &mut self, - gpu_state: &mut GpuState, - doc_rect: skia::Rect, - ) -> Result<()> { - if doc_rect.is_empty() { - return Ok(()); - } - - // Current atlas bounds in document space (1 unit == 1 px). - let current_left = self.atlas_origin.x; - let current_top = self.atlas_origin.y; - let atlas_scale = self.atlas_scale.max(0.01); - let current_right = current_left + (self.atlas_size.width as f32) / atlas_scale; - let current_bottom = current_top + (self.atlas_size.height as f32) / atlas_scale; - - let mut new_left = current_left; - let mut new_top = current_top; - let mut new_right = current_right; - let mut new_bottom = current_bottom; - - // If atlas is empty/uninitialized, seed to rect (expanded to tile boundaries for fewer reallocs). - let needs_init = self.atlas_size.width <= 0 || self.atlas_size.height <= 0; - if needs_init { - new_left = doc_rect.left.floor(); - new_top = doc_rect.top.floor(); - new_right = doc_rect.right.ceil(); - new_bottom = doc_rect.bottom.ceil(); - } else { - new_left = new_left.min(doc_rect.left.floor()); - new_top = new_top.min(doc_rect.top.floor()); - new_right = new_right.max(doc_rect.right.ceil()); - new_bottom = new_bottom.max(doc_rect.bottom.ceil()); - } - - // Add padding to reduce realloc frequency. - let pad = TILE_SIZE; - new_left -= pad; - new_top -= pad; - new_right += pad; - new_bottom += pad; - - let doc_w = (new_right - new_left).max(1.0); - let doc_h = (new_bottom - new_top).max(1.0); - - // Compute atlas scale needed to fit within the fixed texture cap. - // Keep the highest possible scale (closest to 1.0) that still fits. - let cap = self.max_atlas_texture_size.max(TILE_SIZE as i32) as f32; - let required_scale = (cap / doc_w).min(cap / doc_h).clamp(0.01, 1.0); - - // Never upscale the atlas (it would add blur and churn). - let new_scale = self.atlas_scale.min(required_scale).max(0.01); - - let new_w = (doc_w * new_scale).ceil().clamp(1.0, cap) as i32; - let new_h = (doc_h * new_scale).ceil().clamp(1.0, cap) as i32; - - // Fast path: existing atlas already contains the rect. - if !needs_init - && doc_rect.left >= current_left - && doc_rect.top >= current_top - && doc_rect.right <= current_right - && doc_rect.bottom <= current_bottom - { - return Ok(()); - } - - let mut new_atlas = - gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?; - new_atlas.canvas().clear(skia::Color::TRANSPARENT); - - // Copy old atlas into the new one with offset. - if !needs_init { - let old_scale = self.atlas_scale.max(0.01); - let scale_ratio = new_scale / old_scale; - let dx = (current_left - new_left) * new_scale; - let dy = (current_top - new_top) * new_scale; - - let image = self.atlas.image_snapshot(); - let src = skia::Rect::from_xywh( - 0.0, - 0.0, - self.atlas_size.width as f32, - self.atlas_size.height as f32, - ); - let dst = skia::Rect::from_xywh( - dx, - dy, - (self.atlas_size.width as f32) * scale_ratio, - (self.atlas_size.height as f32) * scale_ratio, - ); - new_atlas.canvas().draw_image_rect( - &image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &skia::Paint::default(), - ); - } - - self.atlas_origin = skia::Point::new(new_left, new_top); - self.atlas_size = skia::ISize::new(new_w, new_h); - self.atlas_scale = new_scale; - gpu_state.delete_surface(&mut self.atlas); - self.atlas = new_atlas; - Ok(()) - } - - fn blit_tile_image_into_atlas( - &mut self, - gpu_state: &mut GpuState, - tile_image: &skia::Image, - tile_doc_rect: skia::Rect, - ) -> Result<()> { - if tile_doc_rect.is_empty() { - return Ok(()); - } - - // Clamp to document bounds (if any) and compute a matching source-rect in tile pixels. - let mut clipped_doc_rect = tile_doc_rect; - if let Some(bounds) = self.atlas_doc_bounds { - if !clipped_doc_rect.intersect(bounds) { - return Ok(()); - } - } - if clipped_doc_rect.is_empty() { - return Ok(()); - } - - self.ensure_atlas_contains(gpu_state, clipped_doc_rect)?; - - // Destination is document-space rect mapped into atlas pixel coords. - let dst = skia::Rect::from_xywh( - (clipped_doc_rect.left - self.atlas_origin.x) * self.atlas_scale, - (clipped_doc_rect.top - self.atlas_origin.y) * self.atlas_scale, - clipped_doc_rect.width() * self.atlas_scale, - clipped_doc_rect.height() * self.atlas_scale, - ); - - // Compute source rect in tile_image pixel coordinates. - let img_w = tile_image.width() as f32; - let img_h = tile_image.height() as f32; - let tw = tile_doc_rect.width().max(1.0); - let th = tile_doc_rect.height().max(1.0); - - let sx = ((clipped_doc_rect.left - tile_doc_rect.left) / tw) * img_w; - let sy = ((clipped_doc_rect.top - tile_doc_rect.top) / th) * img_h; - let sw = (clipped_doc_rect.width() / tw) * img_w; - let sh = (clipped_doc_rect.height() / th) * img_h; - let src = skia::Rect::from_xywh(sx, sy, sw, sh); - - self.atlas.canvas().draw_image_rect( - tile_image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &skia::Paint::default(), - ); - Ok(()) - } - - pub fn clear_doc_rect_in_atlas( - &mut self, - gpu_state: &mut GpuState, - doc_rect: skia::Rect, - ) -> Result<()> { - let doc_rect = self.clamp_doc_rect_to_bounds(doc_rect); - if doc_rect.is_empty() { - return Ok(()); - } - - self.ensure_atlas_contains(gpu_state, doc_rect)?; - - // Destination is document-space rect mapped into atlas pixel coords. - let dst = skia::Rect::from_xywh( - (doc_rect.left - self.atlas_origin.x) * self.atlas_scale, - (doc_rect.top - self.atlas_origin.y) * self.atlas_scale, - doc_rect.width() * self.atlas_scale, - doc_rect.height() * self.atlas_scale, - ); - - let canvas = self.atlas.canvas(); - canvas.save(); - canvas.clip_rect(dst, None, true); - canvas.clear(skia::Color::TRANSPARENT); - canvas.restore(); - Ok(()) - } - - /// Clears the last atlas region written by `tile` (if any). - /// - /// This avoids clearing the entire logical tile rect which, at very low - /// zoom levels, can be enormous in document space and would unnecessarily - /// grow / rescale the atlas. - pub fn clear_tile_in_atlas(&mut self, gpu_state: &mut GpuState, tile: Tile) -> Result<()> { - if let Some(doc_rect) = self.atlas_tile_doc_rects.remove(&tile) { - self.clear_doc_rect_in_atlas(gpu_state, doc_rect)?; - } - Ok(()) - } - - /// Clears a doc-space rect from the atlas **without** growing it. - /// - /// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the - /// current atlas bounds and skips silently if there is no overlap. Use this - /// when evicting stale shape content (e.g. before a drag re-render) where - /// growing the atlas to accommodate an out-of-range rect would be wasteful. - pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) { - if !self.has_atlas() || doc_rect.is_empty() { - return; - } - - let atlas_scale = self.atlas_scale.max(0.01); - let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale; - let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale; - - // Intersect with current atlas bounds in doc space. - let mut clipped = doc_rect; - let atlas_bounds = skia::Rect::from_ltrb( - self.atlas_origin.x, - self.atlas_origin.y, - atlas_doc_right, - atlas_doc_bottom, - ); - if !clipped.intersect(atlas_bounds) { - return; - } - - // Apply atlas_doc_bounds clamping. - if let Some(bounds) = self.atlas_doc_bounds { - if !clipped.intersect(bounds) { - return; - } - } - - if clipped.is_empty() { - return; - } - - let dst = skia::Rect::from_xywh( - (clipped.left - self.atlas_origin.x) * atlas_scale, - (clipped.top - self.atlas_origin.y) * atlas_scale, - clipped.width() * atlas_scale, - clipped.height() * atlas_scale, - ); - - let canvas = self.atlas.canvas(); - canvas.save(); - canvas.clip_rect(dst, None, true); - canvas.clear(skia::Color::TRANSPARENT); - canvas.restore(); + pub fn set_dpr(&mut self, dpr: f32) { + self.dpr = dpr; } pub fn clear_tiles(&mut self) { self.tiles.clear(); } - pub fn has_atlas(&self) -> bool { - self.atlas_size.width > 0 && self.atlas_size.height > 0 + pub fn draw_tile_atlas_to_backbuffer(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { + self.tiles.update(viewbox, tile_viewbox); + self.backbuffer.canvas().draw_atlas( + &self.tile_atlas.image_snapshot(), + &self.tiles.transforms, + &self.tiles.textures, + None, + skia::BlendMode::SrcOver, + self.atlas_sampling_options, + None, + None, + ); } /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). /// Clears Backbuffer to `background` first so atlas-uncovered regions don't /// show stale content when the atlas only partially covers the viewport. - pub fn draw_atlas_to_backbuffer( - &mut self, - viewbox: Viewbox, - dpr: f32, - background: skia::Color, - ) { - if !self.has_atlas() { + pub fn draw_atlas_to_backbuffer(&mut self, viewbox: Viewbox, background: skia::Color) { + if self.atlas.is_empty() { return; } @@ -496,15 +552,15 @@ impl Surfaces { ); canvas.clear(background); - let s = viewbox.zoom * dpr; - let atlas_scale = self.atlas_scale.max(0.01); + let s = viewbox.get_scale(); + let scale = self.atlas.scale.max(0.01); canvas.translate(( - (self.atlas_origin.x + viewbox.pan_x) * s, - (self.atlas_origin.y + viewbox.pan_y) * s, + (self.atlas.origin.x + viewbox.pan.x) * s, + (self.atlas.origin.y + viewbox.pan.y) * s, )); - canvas.scale((s / atlas_scale, s / atlas_scale)); + canvas.scale((s / scale, s / scale)); - self.atlas.draw( + self.atlas.surface.draw( canvas, (0.0, 0.0), self.sampling_options, @@ -757,7 +813,8 @@ impl Surfaces { SurfaceId::Debug => &mut self.debug, SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, - SurfaceId::Atlas => &mut self.atlas, + SurfaceId::Atlas => &mut self.atlas.surface, + SurfaceId::TileAtlas => &mut self.tile_atlas, } } @@ -777,7 +834,8 @@ impl Surfaces { SurfaceId::Debug => &self.debug, SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, - SurfaceId::Atlas => &self.atlas, + SurfaceId::Atlas => &self.atlas.surface, + SurfaceId::TileAtlas => &self.tile_atlas, } } @@ -843,12 +901,30 @@ impl Surfaces { .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; self.cache.canvas().reset_matrix(); self.cache.canvas().translate(( - (interest_area_threshold as f32 * TILE_SIZE), - (interest_area_threshold as f32 * TILE_SIZE), + (interest_area_threshold * TILE_SIZE) as f32, + (interest_area_threshold * TILE_SIZE) as f32, )); Ok(()) } + pub fn resize_cache_from_viewbox( + &mut self, + viewbox: &Viewbox, + cached_viewbox: &Viewbox, + interest_area_threshold: i32, + ) -> Result<()> { + let viewbox_cache_size = get_cache_size(viewbox, interest_area_threshold); + let cached_viewbox_cache_size = get_cache_size(cached_viewbox, interest_area_threshold); + // Only resize cache if the new size is larger than the cached size + // This avoids unnecessary surface recreations when the cache size decreases + if viewbox_cache_size.width > cached_viewbox_cache_size.width + || viewbox_cache_size.height > cached_viewbox_cache_size.height + { + return self.resize_cache(viewbox_cache_size, interest_area_threshold); + } + Ok(()) + } + pub fn draw_rect_to( &mut self, id: SurfaceId, @@ -1005,6 +1081,7 @@ impl Surfaces { self.canvas(SurfaceId::Debug) .clear(skia::Color::TRANSPARENT) .reset_matrix(); + self.canvas(SurfaceId::UI) .clear(skia::Color::TRANSPARENT) .reset_matrix(); @@ -1021,7 +1098,7 @@ impl Surfaces { canvas.restore(); } - pub fn cache_current_tile_texture( + pub fn draw_current_tile_into_tile_atlas( &mut self, tile_viewbox: &TileViewbox, tile: &Tile, @@ -1030,15 +1107,9 @@ impl Surfaces { tile_doc_rect: skia::Rect, ) { let gpu_state = get_gpu_state(); - let rect = IRect::from_xywh( - self.margins.width, - self.margins.height, - self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, - self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, - ); + let rect = TILE_DRAWABLE_RECT; let tile_image_opt = self.current.image_snapshot_with_bounds(rect); - if let Some(tile_image) = tile_image_opt { if !skip_cache_surface { // Draw to cache surface for render_from_cache @@ -1052,9 +1123,19 @@ impl Surfaces { // Incrementally update persistent 1:1 atlas in document space. // `tile_doc_rect` is in world/document coordinates (1 unit == 1 px at 100%). - let _ = self.blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); - self.atlas_tile_doc_rects.insert(*tile, tile_doc_rect); - self.tiles.add(tile_viewbox, tile, tile_image); + let _ = self + .atlas + .blit_tile_image_into_atlas(gpu_state, &tile_image, tile_doc_rect); + self.atlas.tile_doc_rects.insert(*tile, tile_doc_rect); + + // Draws current tile into tile atlas + let tile_ref = self.tiles.add(tile_viewbox, tile); + self.tile_atlas.canvas().draw_image_rect( + &tile_image, + None, + tile_ref.rect, + &skia::Paint::default(), + ); } } @@ -1062,21 +1143,6 @@ impl Surfaces { self.tiles.has(tile) } - /// Returns a snapshot of the atlas together with its scale and origin, so the - /// caller can take it **once** per `rebuild_backbuffer_crop_cache` and share it - /// across all shapes that need the tile/atlas fallback path — avoiding an - /// `image_snapshot` (and potential GPU flush) per shape. - pub fn atlas_snapshot_for_drag_crop(&mut self) -> Option<(skia::Image, f32, skia::Point)> { - if !self.has_atlas() { - return None; - } - Some(( - self.atlas.image_snapshot(), - self.atlas_scale.max(0.01), - self.atlas_origin, - )) - } - /// Builds a 1:1 workspace-pixel snapshot for `src_doc_bounds` / `src_irect` into /// `scratch`, then returns the sub-region `[0, out_w) × [0, out_h)` as an image. /// @@ -1085,7 +1151,7 @@ impl Surfaces { /// the largest window needed in one `rebuild_backbuffer_crop_cache` pass. /// /// `atlas_snap` is a pre-snapshotted view of the persistent atlas produced by - /// [`Surfaces::atlas_snapshot_for_drag_crop`]; pass `None` when no atlas exists. + /// [`Surfaces::atlas.snapshot_for_drag_crop`]; pass `None` when no atlas exists. /// /// For each tile cell intersecting `src_doc_bounds`: draws from /// [`TileTextureCache`] when present; otherwise samples the atlas. @@ -1131,7 +1197,17 @@ impl Surfaces { (clip_doc.bottom - vb_top) * scale - iy0, ); - if let Some(tile_image) = self.tiles.get(tile) { + if let Some(tile_ref) = self.tiles.get(tile) { + let bounds = skia::IRect::from_ltrb( + tile_ref.rect.left as i32, + tile_ref.rect.top as i32, + tile_ref.rect.right as i32, + tile_ref.rect.bottom as i32, + ); + let Some(tile_image) = self.tile_atlas.image_snapshot_with_bounds(bounds) + else { + panic!("Cannot retrieve tile image"); + }; let iw = tile_image.width() as f32; let ih = tile_image.height() as f32; let td_w = tile_doc.width().max(1e-6); @@ -1152,9 +1228,9 @@ impl Surfaces { ); } else { let snap = atlas_snap?; - let (atlas, a_scale, atlas_origin) = (&snap.0, snap.1, snap.2); - let sx = (clip_doc.left - atlas_origin.x) * a_scale; - let sy = (clip_doc.top - atlas_origin.y) * a_scale; + let (atlas, a_scale, origin) = (&snap.0, snap.1, snap.2); + let sx = (clip_doc.left - origin.x) * a_scale; + let sy = (clip_doc.top - origin.y) * a_scale; let sw = clip_doc.width() * a_scale; let sh = clip_doc.height() * a_scale; if sw <= 0.0 || sh <= 0.0 { @@ -1182,52 +1258,38 @@ impl Surfaces { self.tiles.remove(tile); // Also clear the corresponding region in the persistent atlas to avoid // leaving stale pixels when shapes move/delete. - let _ = self.clear_tile_in_atlas(gpu_state, tile); + let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile); } - pub fn draw_cached_tile_surface(&mut self, tile: Tile, rect: skia::Rect, color: skia::Color) { - if let Some(image) = self.tiles.get(tile) { - let mut paint = skia::Paint::default(); - paint.set_color(color); + pub fn get_tile_image_from_tile_atlas(&mut self, tile: Tile) -> Option { + let Some(tile_ref) = self.tiles.get(tile) else { + panic!("Tile not found {}:{}", tile.0, tile.1); + }; - self.backbuffer.canvas().draw_rect(rect, &paint); - - self.backbuffer - .canvas() - .draw_image_rect(&image, None, rect, &skia::Paint::default()); - } + let rect = IRect::from_ltrb( + tile_ref.rect.left as i32, + tile_ref.rect.top as i32, + tile_ref.rect.right as i32, + tile_ref.rect.bottom as i32, + ); + self.tile_atlas.image_snapshot_with_bounds(rect) } - /// Draws a cached tile texture to the Cache self.backbuffer at the given - /// cache-aligned rect. This keeps the Cache surface in sync with - /// Backbuffer 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(), - ); + pub fn draw_cached_tile_into_backbuffer(&mut self, tile: Tile, rect: &Rect) { + if let Some(image) = self.get_tile_image_from_tile_atlas(tile) { + // let rect = tile.get_rect_with_offset(&offset); + let backbuffer_canvas = self.backbuffer.canvas(); + backbuffer_canvas.draw_image_rect(&image, None, rect, &skia::Paint::default()); } } /// Draws the current tile directly to the backbuffer 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). - pub fn draw_current_tile_direct( + pub fn draw_current_tile_into_backbuffer( &mut self, tile_rect: &skia::Rect, - color: skia::Color, + _color: skia::Color, draw_on_cache: DrawOnCache, ) { let sampling_options = self.sampling_options; @@ -1240,10 +1302,11 @@ impl Surfaces { let src_rect_f = skia::Rect::from(src_rect); let backbuffer_canvas = self.backbuffer.canvas(); + // Draw background - let mut paint = skia::Paint::default(); - paint.set_color(color); - backbuffer_canvas.draw_rect(tile_rect, &paint); + // let mut paint = skia::Paint::default(); + // paint.set_color(color); + // backbuffer_canvas.draw_rect(tile_rect, &paint); // Draw current surface directly to target (no snapshot) self.current.draw( @@ -1275,7 +1338,7 @@ impl Surfaces { /// the cache canvas for scaled previews, use `invalidate_tile_cache` instead. pub fn remove_cached_tiles(&mut self, color: skia::Color) { self.tiles.clear(); - self.atlas_tile_doc_rects.clear(); + self.atlas.tile_doc_rects.clear(); self.cache.canvas().clear(color); } @@ -1285,7 +1348,7 @@ impl Surfaces { /// content while new tiles are being rendered. pub fn invalidate_tile_cache(&mut self) { self.tiles.clear(); - self.atlas_tile_doc_rects.clear(); + self.atlas.tile_doc_rects.clear(); } pub fn gc(&mut self) { @@ -1347,31 +1410,103 @@ impl Surfaces { } } +#[derive(Debug, Clone)] +pub struct TileAtlasTextureRef { + pub index: usize, + pub rect: skia::Rect, +} + +impl TileAtlasTextureRef { + pub fn new(index: usize, rect: skia::Rect) -> Self { + Self { index, rect } + } +} + +pub struct TileAtlasTextureProvider { + pub index: usize, + pub length: usize, + pub in_use: Vec, + pub rects: Vec, +} + +impl TileAtlasTextureProvider { + pub fn new(texture_size: i32, tile_size: i32) -> Self { + let side = texture_size / tile_size; + let length = side * side; + let mut rects = Vec::with_capacity(length as usize); + for i in 0..length { + let left = (i % side) as f32 * tile_size as f32; + let top = (i / side) as f32 * tile_size as f32; + let right = left + tile_size as f32; + let bottom = top + tile_size as f32; + rects.push(Rect::new(left, top, right, bottom)); + } + Self { + index: 0, + length: length as usize, + in_use: vec![false; length as usize], + rects, + } + } + + pub fn allocate(&mut self) -> Option { + let start = self.index; + loop { + if !self.in_use[self.index] { + self.in_use[self.index] = true; + return Some(TileAtlasTextureRef::new(self.index, self.rects[self.index])); + } + + self.index = (self.index + 1) % self.length; + if self.index == start { + return None; + } + } + } + + pub fn deallocate(&mut self, reference: TileAtlasTextureRef) -> bool { + // In this case the user of the provider it's trying to release + // a reference already freed. + if !self.in_use[reference.index] { + return false; + } + self.in_use[reference.index] = false; + self.index = reference.index; + true + } +} + pub struct TileTextureCache { - grid: HashMap, + tile_size: f32, + provider: TileAtlasTextureProvider, + transforms: Vec, + textures: Vec, + grid: HashMap, removed: HashSet, } impl TileTextureCache { - pub fn new() -> Self { + pub fn new(texture_size: i32, capacity: usize) -> Self { Self { - grid: HashMap::default(), - removed: HashSet::default(), + tile_size: tiles::TILE_SIZE, + provider: TileAtlasTextureProvider::new(texture_size, TILE_SIZE), + transforms: Vec::with_capacity(capacity), + textures: Vec::with_capacity(capacity), + grid: HashMap::with_capacity(capacity), + removed: HashSet::with_capacity(capacity), } } - pub fn has(&self, tile: Tile) -> bool { - self.grid.contains_key(&tile) && !self.removed.contains(&tile) - } - fn gc(&mut self) { // Make a real remove for tile in self.removed.iter() { - self.grid.remove(tile); + if let Some(tile_ref) = self.grid.remove(tile) { + self.provider.deallocate(tile_ref); + } } } - fn free_tiles(&mut self, tile_viewbox: &TileViewbox) { + fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { let marked: Vec<_> = self .grid .iter_mut() @@ -1386,35 +1521,99 @@ impl TileTextureCache { .collect(); for tile in marked.iter() { - self.grid.remove(tile); + if let Some(tile_ref) = self.grid.remove(tile) { + self.provider.deallocate(tile_ref); + } } } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile, image: skia::Image) { + pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { + if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { + self.transforms.resize( + tile_viewbox.visible_rect.len() as usize, + skia::RSXform::new(1.0, 0.0, Point::default()), + ); + } + + if self.textures.len() != tile_viewbox.visible_rect.len() as usize { + self.textures.resize( + tile_viewbox.visible_rect.len() as usize, + skia::Rect::new_empty(), + ); + } + + for texture in self.textures.iter_mut() { + texture.set_empty(); + } + + let offset = viewbox.get_offset(); + let mut index = 0; + for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { + for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { + let tile = Tile(x, y); + + let Some(tile_ref) = self.grid.get(&tile) else { + continue; + }; + + self.transforms[index].tx = x as f32 * self.tile_size - offset.x; + self.transforms[index].ty = y as f32 * self.tile_size - offset.y; + + self.textures[index].set_ltrb( + tile_ref.rect.left, + tile_ref.rect.top, + tile_ref.rect.right, + tile_ref.rect.bottom, + ); + + index += 1; + } + } + } + + pub fn has(&self, tile: Tile) -> bool { + self.grid.contains_key(&tile) && !self.removed.contains(&tile) + } + + pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> TileAtlasTextureRef { if self.grid.len() > TEXTURES_CACHE_CAPACITY { - // First we try to remove the obsolete tiles + // First we try to remove the obsolete tiles. self.gc(); } + // If we still have a texture capacity problem, then + // we try to remove all of those tiles that aren't + // visible. if self.grid.len() > TEXTURES_CACHE_CAPACITY { - self.free_tiles(tile_viewbox); + self.gc_non_visible(tile_viewbox); } - self.grid.insert(*tile, image); + let Some(tile_ref) = self.provider.allocate() else { + panic!("Tile texture allocation failed {}:{}", tile.0, tile.1); + }; + + self.grid.insert(*tile, tile_ref.clone()); if self.removed.contains(tile) { self.removed.remove(tile); } + + tile_ref.clone() } - pub fn get(&mut self, tile: Tile) -> Option<&mut skia::Image> { + pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { if self.removed.contains(&tile) { return None; } - self.grid.get_mut(&tile) + self.grid.get(&tile) } pub fn remove(&mut self, tile: Tile) { + if let Some(tile_ref) = self.grid.get(&tile) { + if tile_ref.index < self.textures.len() { + self.textures[tile_ref.index].set_empty(); + } + } self.removed.insert(tile); } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index cbddd9e290..ff459481f7 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -3,8 +3,8 @@ use crate::{ error::Result, math::Rect, shapes::{ - calculate_position_data, calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, - Stroke, StrokeKind, TextContent, + calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind, + TextContent, }, utils::{get_fallback_fonts, get_font_collection}, }; @@ -12,7 +12,7 @@ use skia_safe::{ self as skia, canvas::SaveLayerRec, textlayout::{ParagraphBuilder, StyleMetrics, TextDecoration, TextStyle}, - Canvas, ImageFilter, Paint, Path, + Canvas, ImageFilter, Paint, }; pub fn stroke_paragraph_builder_group_from_text( @@ -552,78 +552,6 @@ pub fn calculate_decoration_metrics( ) } -#[allow(dead_code)] -fn calculate_total_paragraphs_height(paragraphs: &mut [ParagraphBuilder], width: f32) -> f32 { - paragraphs - .iter_mut() - .map(|p| { - let mut paragraph = p.build(); - paragraph.layout(width); - paragraph.height() - }) - .sum() -} - -#[allow(dead_code)] -fn calculate_all_paragraphs_height( - paragraph_groups: &mut [Vec], - width: f32, -) -> f32 { - paragraph_groups - .iter_mut() - .map(|group| { - // For stroke groups, only count the first paragraph to avoid double-counting - if group.len() > 1 { - let mut paragraph = group[0].build(); - paragraph.layout(width); - paragraph.height() - } else { - calculate_total_paragraphs_height(group, width) - } - }) - .sum() -} - -// Render text paths (unused) -#[allow(dead_code)] -pub fn render_as_path( - render_state: &mut RenderState, - paths: &Vec<(Path, Paint)>, - surface_id: Option, -) { - let canvas = render_state - .surfaces - .canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Fills)); - - for (path, paint) in paths { - // Note: path can be empty - canvas.draw_path(path, paint); - } -} - -#[allow(dead_code)] -pub fn render_position_data( - render_state: &mut RenderState, - surface_id: SurfaceId, - shape: &Shape, - text_content: &TextContent, -) { - let position_data = calculate_position_data(shape, text_content, false); - - let mut paint = skia::Paint::default(); - paint.set_style(skia::PaintStyle::Stroke); - paint.set_color(skia::Color::from_argb(255, 255, 0, 0)); - paint.set_stroke_width(2.); - - for pd in position_data { - let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height); - render_state - .surfaces - .canvas_and_mark_dirty(surface_id) - .draw_rect(rect, &paint); - } -} - // How to use it? // Type::Text(text_content) => { // self.surfaces diff --git a/render-wasm/src/shapes/layouts.rs b/render-wasm/src/shapes/layouts.rs index 2da92ad886..c53e81eee1 100644 --- a/render-wasm/src/shapes/layouts.rs +++ b/render-wasm/src/shapes/layouts.rs @@ -1,7 +1,6 @@ use crate::uuid::Uuid; #[derive(Debug, Clone, PartialEq)] -#[allow(dead_code)] pub enum Layout { FlexLayout(LayoutData, FlexData), GridLayout(LayoutData, GridData), diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 9f28887df2..109fc605b2 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -501,7 +501,6 @@ pub fn propagate_modifiers( } } - // #[allow(dead_code)] Ok(modifiers .iter() .map(|(key, val)| TransformEntry::from_input(*key, *val)) diff --git a/render-wasm/src/shapes/modifiers/flex_layout.rs b/render-wasm/src/shapes/modifiers/flex_layout.rs index 80fa3e6fcb..7c7b911c1f 100644 --- a/render-wasm/src/shapes/modifiers/flex_layout.rs +++ b/render-wasm/src/shapes/modifiers/flex_layout.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use crate::error::{Error, Result}; use crate::math::{self as math, Bounds, Matrix, Point, Vector, VectorExt}; use crate::shapes::{ @@ -123,7 +121,7 @@ struct ChildAxis { max_across_size: f32, is_fill_main: bool, is_fill_across: bool, - z_index: i32, + _z_index: i32, // unused bounds: Bounds, } @@ -146,7 +144,7 @@ impl ChildAxis { max_across_size: layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE), is_fill_main: child.is_layout_horizontal_fill(), is_fill_across: child.is_layout_vertical_fill(), - z_index: layout_item.and_then(|i| i.z_index).unwrap_or(0), + _z_index: layout_item.and_then(|i| i.z_index).unwrap_or(0), bounds: *child_bounds, } } else { @@ -164,7 +162,7 @@ impl ChildAxis { max_main_size: layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE), is_fill_main: child.is_layout_vertical_fill(), is_fill_across: child.is_layout_horizontal_fill(), - z_index: layout_item.and_then(|i| i.z_index).unwrap_or(0), + _z_index: layout_item.and_then(|i| i.z_index).unwrap_or(0), bounds: *child_bounds, } }; diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index c5dec42f4a..86159ac3df 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -115,7 +115,6 @@ impl TextContentSize { #[derive(Debug, Clone, Copy, Default)] pub struct TextPositionWithAffinity { - #[allow(dead_code)] pub position_with_affinity: PositionWithAffinity, pub paragraph: usize, pub offset: usize, @@ -244,9 +243,9 @@ impl TextContentLayout { } } -#[allow(dead_code)] #[derive(Debug, Clone)] pub struct TextDecorationSegment { + #[allow(dead_code)] pub kind: skia::textlayout::TextDecoration, pub text_style: skia::textlayout::TextStyle, pub y: f32, @@ -392,16 +391,6 @@ impl TextContent { self.bounds = Rect::from_xywh(x, y, w, h); } - #[allow(dead_code)] - pub fn x(&self) -> f32 { - self.bounds.x() - } - - #[allow(dead_code)] - pub fn y(&self) -> f32 { - self.bounds.y() - } - pub fn add_paragraph(&mut self, paragraph: Paragraph) { self.paragraphs.push(paragraph); self.content_version = self.content_version.wrapping_add(1); @@ -621,10 +610,6 @@ impl TextContent { let mut offset_y = 0.0; let layout_paragraphs = self.layout.paragraphs.iter().flatten(); - // IMPORTANT! I'm keeping this because I think it should be better to have the span index - // cached the same way we keep the paragraph index. - #[allow(dead_code)] - let mut _span_index: usize = 0; for (paragraph_index, layout_paragraph) in layout_paragraphs.enumerate() { let start_y = offset_y; let end_y = offset_y + layout_paragraph.height(); @@ -652,15 +637,8 @@ impl TextContent { // in which span we are. let mut computed_position: usize = 0; - // This could be useful in the future as part of the TextPositionWithAffinity. - #[allow(dead_code)] - let mut _span_offset: usize = 0; - // If paragraph has no spans, default to span 0, offset 0 - if paragraph.children().is_empty() { - _span_index = 0; - _span_offset = 0; - } else { + if !paragraph.children().is_empty() { for span in paragraph.children() { let length = span.text.chars().count(); let start_position = computed_position; @@ -670,19 +648,15 @@ impl TextContent { // Handle empty spans: if the span is empty and current position // matches the start, this is the right span if length == 0 && current_position == start_position { - _span_offset = 0; break; } if start_position <= current_position && end_position >= current_position { - _span_offset = - position_with_affinity.position as usize - start_position; break; } computed_position += length; - _span_index += 1; } } @@ -1152,11 +1126,6 @@ impl Paragraph { } } - #[allow(dead_code)] - fn set_children(&mut self, children: Vec) { - self.children = children; - } - pub fn children(&self) -> &[TextSpan] { &self.children } @@ -1165,11 +1134,6 @@ impl Paragraph { &mut self.children } - #[allow(dead_code)] - fn add_span(&mut self, span: TextSpan) { - self.children.push(span); - } - pub fn line_height(&self) -> f32 { self.line_height } @@ -1306,11 +1270,6 @@ impl TextSpan { self.text = text; } - #[allow(dead_code)] - pub fn fills(&self) -> &[shapes::Fill] { - &self.fills - } - pub fn to_style( &self, content_bounds: &Rect, @@ -1415,7 +1374,6 @@ impl TextSpan { } } -#[allow(dead_code)] #[derive(Debug, Copy, Clone)] pub struct PositionData { pub paragraph: u32, @@ -1429,21 +1387,17 @@ pub struct PositionData { pub direction: u32, } -#[allow(dead_code)] #[derive(Debug)] pub struct ParagraphLayout { pub paragraph: skia::textlayout::Paragraph, pub x: f32, pub y: f32, - pub spans: Vec, pub decorations: Vec, } -#[allow(dead_code)] #[derive(Debug)] pub struct TextLayoutData { pub position_data: Vec, - pub content_rect: Rect, pub paragraphs: Vec, } @@ -1508,12 +1462,6 @@ pub fn calculate_text_layout_data( for (i, group_paragraphs) in built_groups.into_iter().enumerate() { // For each paragraph in the group (e.g., fill, stroke, etc.) for skia_paragraph in group_paragraphs.into_iter() { - let spans = if let Some(text_para) = text_paragraphs.get(i) { - text_para.children().to_vec() - } else { - Vec::new() - }; - // Calculate text decorations for this paragraph let mut decorations = Vec::new(); let line_metrics = skia_paragraph.get_line_metrics(); @@ -1580,7 +1528,6 @@ pub fn calculate_text_layout_data( paragraph: skia_paragraph, x, y: y_accum, - spans: spans.clone(), decorations, }); } @@ -1644,10 +1591,8 @@ pub fn calculate_text_layout_data( } } - let content_rect = Rect::from_xywh(x, base_y + vertical_offset, text_width, total_text_height); TextLayoutData { position_data, - content_rect, paragraphs: paragraph_layouts, } } diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index c622372a46..740ef9deef 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -7,6 +7,7 @@ pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef}; pub use text_editor::*; use crate::error::{Error, Result}; +use crate::render::FrameType; use crate::shapes::{grid_layout::grid_cell_data, Shape}; use crate::uuid::Uuid; use crate::{get_render_state, tiles}; @@ -64,11 +65,11 @@ impl State { get_render_state().render_from_cache(&self.shapes); } - pub fn render_sync(&mut self, timestamp: i32) -> Result<()> { + pub fn render_sync(&mut self, timestamp: i32) -> Result { get_render_state().start_render_loop(None, &self.shapes, timestamp, true) } - pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result<()> { + pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result { get_render_state().start_render_loop(Some(id), &self.shapes, timestamp, true) } @@ -81,7 +82,7 @@ impl State { get_render_state().render_shape_pixels(id, &self.shapes, scale, timestamp) } - pub fn start_render_loop(&mut self, timestamp: i32) -> Result<()> { + pub fn start_render_loop(&mut self, timestamp: i32) -> Result { let render_state = get_render_state(); // If zoom changed (e.g. interrupted zoom render followed by pan), the // tile index may be stale for the new viewport position. Rebuild the @@ -92,12 +93,11 @@ impl State { if render_state.zoom_changed() { render_state.rebuild_tile_index(&self.shapes); } - render_state.start_render_loop(None, &self.shapes, timestamp, false) } - pub fn process_animation_frame(&mut self, timestamp: i32) -> Result<()> { - get_render_state().process_animation_frame(None, &self.shapes, timestamp) + pub fn continue_render_loop(&mut self, timestamp: i32) -> Result { + get_render_state().continue_render_loop(None, &self.shapes, timestamp) } pub fn clear_focus_mode(&mut self) { diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 974c1c74ce..8277bfc832 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -426,8 +426,6 @@ impl TextEditorState { let Some(last_paragraph) = text_content.paragraphs().last() else { return false; }; - #[allow(dead_code)] - let _num_spans = last_paragraph.children().len() - 1; let Some(_last_text_span) = last_paragraph.children().last() else { return false; }; diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 9d94de87fc..0d32b1ae1c 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -10,80 +10,169 @@ impl Tile { pub fn from(x: i32, y: i32) -> Self { Tile(x, y) } + + #[inline(always)] pub fn x(&self) -> i32 { self.0 } + + #[inline(always)] pub fn y(&self) -> i32 { self.1 } + + #[inline(always)] + pub fn get_rect_with_size(&self, tile_size: f32) -> skia::Rect { + skia::Rect::from_xywh( + self.0 as f32 * tile_size, + self.1 as f32 * tile_size, + tile_size, + tile_size, + ) + } + + #[inline(always)] + pub fn get_rect_with_offset(&self, offset: &skia::Point) -> skia::Rect { + skia::Rect::from_xywh( + self.0 as f32 * TILE_SIZE - offset.x, + self.1 as f32 * TILE_SIZE - offset.y, + TILE_SIZE, + TILE_SIZE, + ) + } } #[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)] pub struct TileRect(pub i32, pub i32, pub i32, pub i32); +#[allow(dead_code)] impl TileRect { pub fn empty() -> Self { Self(0, 0, 0, 0) } - #[inline] + #[inline(always)] + pub fn is_degenerate(&self) -> bool { + self.left() > self.right() || self.top() > self.bottom() + } + + #[inline(always)] + pub fn len(&self) -> i32 { + (self.width() + 1) * (self.height() + 1) + } + + #[inline(always)] pub fn x1(&self) -> i32 { self.0 } - #[inline] + #[inline(always)] pub fn y1(&self) -> i32 { self.1 } - #[inline] + #[inline(always)] pub fn x2(&self) -> i32 { self.2 } - #[inline] + #[inline(always)] pub fn y2(&self) -> i32 { self.3 } - #[inline] + #[inline(always)] pub fn left(&self) -> i32 { self.0 } - #[inline] + #[inline(always)] pub fn top(&self) -> i32 { self.1 } - #[inline] + #[inline(always)] pub fn right(&self) -> i32 { self.2 } - #[inline] + #[inline(always)] pub fn bottom(&self) -> i32 { self.3 } /// Inclusive tile count on X (matches `contains`: both `x1` and `x2` are included). - #[inline] + #[inline(always)] pub fn columns(&self) -> i32 { self.x2() - self.x1() + 1 } /// Inclusive tile count on Y (matches `contains`: both `y1` and `y2` are included). - #[inline] + #[inline(always)] pub fn rows(&self) -> i32 { self.y2() - self.y1() + 1 } + #[inline(always)] + pub fn width(&self) -> i32 { + self.x2() - self.x1() + } + + #[inline(always)] + pub fn height(&self) -> i32 { + self.y2() - self.y1() + } + + #[inline(always)] pub fn contains(&self, tile: &Tile) -> bool { tile.x() >= self.left() && tile.y() >= self.top() && tile.x() <= self.right() && tile.y() <= self.bottom() } + + pub fn iter(self, inclusive: bool) -> TileRectIter { + TileRectIter::new(self, inclusive) + } +} + +#[allow(dead_code)] +pub struct TileRectIter { + rect: TileRect, + inclusive: bool, + index: i32, + total: i32, +} + +impl TileRectIter { + fn new(rect: TileRect, inclusive: bool) -> Self { + let width = rect.width() + if inclusive { 1 } else { 0 }; + let height = rect.height() + if inclusive { 1 } else { 0 }; + Self { + rect, + inclusive, + index: 0, + total: width * height, + } + } +} + +impl Iterator for TileRectIter { + type Item = Tile; + fn next(&mut self) -> Option { + if self.index >= self.total { + return None; + } + + let width = self.rect.width() + if self.inclusive { 1 } else { 0 }; + + let x = self.rect.left() + self.index % width; + let y = self.rect.top() + self.index / width; + + self.index += 1; + + Some(Tile::from(x, y)) + } } #[derive(Debug)] @@ -95,19 +184,19 @@ pub struct TileViewbox { } impl TileViewbox { - pub fn new_with_interest(viewbox: Viewbox, interest: i32, scale: f32) -> Self { + pub fn new_with_interest(viewbox: &Viewbox, interest: i32) -> Self { Self { - visible_rect: get_tiles_for_viewbox(viewbox, scale), - interest_rect: get_tiles_for_viewbox_with_interest(viewbox, interest, scale), + visible_rect: get_tiles_for_viewbox(viewbox), + interest_rect: get_tiles_for_viewbox_with_interest(viewbox, interest), interest, - center: get_tile_center_for_viewbox(viewbox, scale), + center: get_tile_center_for_viewbox(viewbox), } } - pub fn update(&mut self, viewbox: Viewbox, scale: f32) { - self.visible_rect = get_tiles_for_viewbox(viewbox, scale); - self.interest_rect = get_tiles_for_viewbox_with_interest(viewbox, self.interest, scale); - self.center = get_tile_center_for_viewbox(viewbox, scale); + pub fn update(&mut self, viewbox: &Viewbox) { + self.visible_rect = get_tiles_for_viewbox(viewbox); + self.interest_rect = get_tiles_for_viewbox_with_interest(viewbox, self.interest); + self.center = get_tile_center_for_viewbox(viewbox); } pub fn set_interest(&mut self, interest: i32) { @@ -122,6 +211,7 @@ impl TileViewbox { pub const TILE_SIZE: f32 = 512.; +#[inline(always)] pub fn get_tile_dimensions() -> skia::ISize { (TILE_SIZE as i32, TILE_SIZE as i32).into() } @@ -136,22 +226,18 @@ pub fn get_tiles_for_rect(rect: skia::Rect, tile_size: f32) -> TileRect { TileRect(sx, sy, ex, ey) } -pub fn get_tiles_for_viewbox(viewbox: Viewbox, scale: f32) -> TileRect { - let tile_size = get_tile_size(scale); +pub fn get_tiles_for_viewbox(viewbox: &Viewbox) -> TileRect { + let tile_size = get_tile_size(viewbox.get_scale()); get_tiles_for_rect(viewbox.area, tile_size) } -pub fn get_tiles_for_viewbox_with_interest( - viewbox: Viewbox, - interest: i32, - scale: f32, -) -> TileRect { - let TileRect(sx, sy, ex, ey) = get_tiles_for_viewbox(viewbox, scale); +pub fn get_tiles_for_viewbox_with_interest(viewbox: &Viewbox, interest: i32) -> TileRect { + let TileRect(sx, sy, ex, ey) = get_tiles_for_viewbox(viewbox); TileRect(sx - interest, sy - interest, ex + interest, ey + interest) } -pub fn get_tile_center_for_viewbox(viewbox: Viewbox, scale: f32) -> Tile { - let TileRect(sx, sy, ex, ey) = get_tiles_for_viewbox(viewbox, scale); +pub fn get_tile_center_for_viewbox(viewbox: &Viewbox) -> Tile { + let TileRect(sx, sy, ex, ey) = get_tiles_for_viewbox(viewbox); Tile((ex - sx) / 2, (ey - sy) / 2) } @@ -172,7 +258,7 @@ pub fn get_tile_rect(tile: Tile, scale: f32) -> skia::Rect { skia::Rect::from_xywh(tx, ty, ts, ts) } -// This structure is usseful to keep all the shape uuids by shape id. +// This structure is useful to keep all the shape uuids by shape id. pub struct TileHashMap { grid: HashMap>, index: HashMap>, @@ -186,6 +272,13 @@ impl TileHashMap { } } + pub fn is_empty_at(&self, tile: Tile) -> bool { + if let Some(uuids) = self.grid.get(&tile) { + return uuids.is_empty(); + } + true + } + pub fn get_shapes_at(&mut self, tile: Tile) -> Option<&HashSet> { self.grid.get(&tile) } @@ -219,7 +312,7 @@ impl TileHashMap { } const VIEWPORT_DEFAULT_CAPACITY: usize = 24 * 12; -const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = 64; +const VIEWPORT_SPIRAL_DEFAULT_CAPACITY: usize = VIEWPORT_DEFAULT_CAPACITY; /// Cached spiral of tile offsets for a given grid size. /// @@ -315,6 +408,10 @@ pub struct PendingTiles { pub list: Vec, pub spiral: TileSpiral, pub spiral_rect: TileRect, + pub visible_cached: Vec, + pub visible_uncached: Vec, + pub interest_cached: Vec, + pub interest_uncached: Vec, } impl PendingTiles { @@ -323,6 +420,10 @@ impl PendingTiles { list: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), spiral: TileSpiral::new(), spiral_rect: TileRect::empty(), + visible_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + visible_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + interest_cached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), + interest_uncached: Vec::with_capacity(VIEWPORT_DEFAULT_CAPACITY), } } @@ -356,10 +457,10 @@ impl PendingTiles { // 2. visible + uncached (user sees these, render next) // 3. interest + cached (pre-rendered area, blit from cache) // 4. interest + uncached (lowest priority - background pre-render) - let mut visible_cached = Vec::new(); - let mut visible_uncached = Vec::new(); - let mut interest_cached = Vec::new(); - let mut interest_uncached = Vec::new(); + self.visible_cached.clear(); + self.visible_uncached.clear(); + self.interest_cached.clear(); + self.interest_uncached.clear(); // Compute the scheduling center explicitly (inclusive range). // This avoids relying on `TileRect::center_x/center_y` semantics, which may be used @@ -374,19 +475,19 @@ impl PendingTiles { let is_cached = surfaces.has_cached_tile_surface(tile); match (is_visible, is_cached) { - (true, true) => visible_cached.push(tile), - (true, false) => visible_uncached.push(tile), - (false, true) => interest_cached.push(tile), - (false, false) => interest_uncached.push(tile), + (true, true) => self.visible_cached.push(tile), + (true, false) => self.visible_uncached.push(tile), + (false, true) => self.interest_cached.push(tile), + (false, false) => self.interest_uncached.push(tile), } } // Build final list with lowest priority first (they get popped last) // Order: interest_uncached, interest_cached, visible_uncached, visible_cached - self.list.extend(interest_uncached); - self.list.extend(interest_cached); - self.list.extend(visible_uncached); - self.list.extend(visible_cached); + 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()); } pub fn pop(&mut self) -> Option { diff --git a/render-wasm/src/view.rs b/render-wasm/src/view.rs index 8f21a27eae..efa5394012 100644 --- a/render-wasm/src/view.rs +++ b/render-wasm/src/view.rs @@ -1,62 +1,86 @@ -use skia_safe::Rect; - -use crate::math::{Matrix, Point}; +use crate::math::{Matrix, Point, Rect, Size}; +use std::ops::Mul; #[derive(Debug, Copy, Clone)] pub(crate) struct Viewbox { - pub pan_x: f32, - pub pan_y: f32, - pub width: f32, - pub height: f32, + pub pan: Point, + pub size: Size, pub zoom: f32, + pub dpr: f32, pub area: Rect, } impl Default for Viewbox { fn default() -> Self { Self { - pan_x: 0., - pan_y: 0., - width: 0.0, - height: 0.0, + pan: Point::new(0.0, 0.0), + size: Size::new(0.0, 0.0), zoom: 1.0, + dpr: 1.0, area: Rect::new_empty(), } } } +#[allow(dead_code)] impl Viewbox { pub fn new(width: f32, height: f32) -> Self { - let area = Rect::from_xywh(0., 0., width, height); + let size = Size::new(width, height); + let area = Rect::from_size(size); Self { - width, - height, + size, area, ..Self::default() } } + pub fn dpr_width(&self) -> f32 { + self.size.width * self.dpr + } + + pub fn dpr_height(&self) -> f32 { + self.size.height * self.dpr + } + + pub fn width(&self) -> f32 { + self.size.width + } + + pub fn height(&self) -> f32 { + self.size.height + } + pub fn set_all(&mut self, zoom: f32, pan_x: f32, pan_y: f32) { - self.pan_x = pan_x; - self.pan_y = pan_y; + self.pan.set(pan_x, pan_y); self.zoom = zoom; self.area.set_xywh( - -self.pan_x, - -self.pan_y, - self.width / self.zoom, - self.height / self.zoom, + -self.pan.x, + -self.pan.y, + self.size.width / self.zoom, + self.size.height / self.zoom, ); } pub fn set_wh(&mut self, width: f32, height: f32) { - self.width = width; - self.height = height; + self.size.set(width, height); self.area - .set_wh(self.width / self.zoom, self.height / self.zoom); + .set_wh(self.size.width / self.zoom, self.size.height / self.zoom); + } + + pub fn set_dpr(&mut self, dpr: f32) { + self.dpr = dpr; + } + + pub fn get_scale(&self) -> f32 { + self.zoom * self.dpr + } + + pub fn get_offset(&self) -> Point { + self.area.tl().mul(self.get_scale()) } pub fn pan(&self) -> Point { - Point::new(self.pan_x, self.pan_y) + self.pan } pub fn zoom(&self) -> f32 { diff --git a/render-wasm/src/wapi.rs b/render-wasm/src/wapi.rs index f9e7e65769..5d4ef780db 100644 --- a/render-wasm/src/wapi.rs +++ b/render-wasm/src/wapi.rs @@ -1,40 +1,3 @@ -#[macro_export] -macro_rules! request_animation_frame { - () => {{ - #[cfg(target_arch = "wasm32")] - unsafe extern "C" { - pub fn wapi_requestAnimationFrame() -> i32; - } - - #[cfg(target_arch = "wasm32")] - let result = unsafe { wapi_requestAnimationFrame() }; - #[cfg(not(target_arch = "wasm32"))] - let result = 0; - - result - }}; -} - -#[macro_export] -macro_rules! cancel_animation_frame { - ($frame_id:expr) => { - #[cfg(target_arch = "wasm32")] - unsafe extern "C" { - pub fn wapi_cancelAnimationFrame(frame_id: i32); - } - - { - let frame_id = $frame_id; - #[cfg(target_arch = "wasm32")] - unsafe { - wapi_cancelAnimationFrame(frame_id) - }; - #[cfg(not(target_arch = "wasm32"))] - let _ = frame_id; - } - }; -} - #[macro_export] macro_rules! notify_tiles_render_complete { () => {{ @@ -50,6 +13,4 @@ macro_rules! notify_tiles_render_complete { }}; } -pub use cancel_animation_frame; pub use notify_tiles_render_complete; -pub use request_animation_frame;