diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index 948ce72d4a..1158ec19e8 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -197,11 +197,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (assoc-in [:workspace-local :panning] true))) - - ptk/EffectEvent - (effect [_ state _] - (dwvw/maybe-view-interaction-start! state)))) + (assoc-in [:workspace-local :panning] true))))) (defn start-panning [] (ptk/reify ::start-panning @@ -229,8 +225,4 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :workspace-local dissoc :panning))) - - ptk/EffectEvent - (effect [_ state _] - (dwvw/maybe-view-interaction-end! state)))) + (update :workspace-local dissoc :panning))))) diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs index 4115589ab0..186b4e0123 100644 --- a/frontend/src/app/main/data/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -19,12 +19,12 @@ (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) (wasm.api/sync-workspace-local-viewport! state))) -(defn maybe-view-interaction-start! +#_(defn maybe-view-interaction-start! [state] (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) (wasm.api/view-interaction-start!))) -(defn maybe-view-interaction-end! +#_(defn maybe-view-interaction-end! [state] (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) - (wasm.api/view-interaction-end!))) \ No newline at end of file + (wasm.api/view-interaction-end!))) diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index 1a5afc7d0b..d59f3b2be5 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -199,7 +199,6 @@ (when (and (not (dwvw/render-context-lost? state)) (not (get-in state [:workspace-local :zooming]))) (rx/concat - (rx/of (fn [s] (dwvw/maybe-view-interaction-start! s) s)) (rx/of #(-> % (assoc-in [:workspace-local :zooming] true))) (->> stream (rx/filter mse/pointer-event?) @@ -215,7 +214,4 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :workspace-local dissoc :zooming))) - ptk/EffectEvent - (effect [_ state _] - (dwvw/maybe-view-interaction-end! state)))) + (update :workspace-local dissoc :zooming))))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index cc0814a6b2..277d7dab66 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -362,7 +362,7 @@ ([] (internal-render 0)) ([timestamp] - (set! wasm/internal-frame-type (h/call wasm/internal-module "_render" timestamp wasm/internal-frame-type)) + (set! wasm/internal-frame-type (h/call wasm/internal-module "_render2" timestamp wasm/internal-frame-type)) (when (= wasm/internal-frame-type FRAME_TYPE_PARTIAL) (request-render "frame-type-partial")))) @@ -1297,13 +1297,13 @@ (= result 1)) false)) -(defn view-interaction-start! +#_(defn view-interaction-start! [] (when-not @view-interaction-active? (h/call wasm/internal-module "_set_view_start") (reset! view-interaction-active? true))) -(defn view-interaction-end! +#_(defn view-interaction-end! [] (when @view-interaction-active? (perf/begin-measure "render-finish") @@ -1311,33 +1311,35 @@ (perf/end-measure "render-finish") (reset! view-interaction-active? false))) -(def render-finish - (letfn [(do-render [] - ;; Check if context is still initialized before executing - ;; to prevent errors when navigating quickly - (when (initialized?) - (view-interaction-end!) - ;; Use async _render: visible tiles render synchronously - ;; (no yield), interest-area tiles render progressively - ;; via rAF. _set_view_end already rebuilt the tile - ;; index. For pan, most tiles are cached so the render - ;; completes in the first frame. For zoom, interest- - ;; area tiles (~3 tile margin) don't block the main - ;; thread. - (internal-render)))] - (fns/debounce do-render DEBOUNCE_DELAY_MS))) +#_(def render-finish + (letfn [(do-render [] + ;; Check if context is still initialized before executing + ;; to prevent errors when navigating quickly + (when (initialized?) + #_(view-interaction-end!) + ;; Use async _render: visible tiles render synchronously + ;; (no yield), interest-area tiles render progressively + ;; via rAF. _set_view_end already rebuilt the tile + ;; index. For pan, most tiles are cached so the render + ;; completes in the first frame. For zoom, interest- + ;; area tiles (~3 tile margin) don't block the main + ;; thread. + (internal-render)))] + (fns/debounce do-render DEBOUNCE_DELAY_MS))) (defn set-view-box [zoom vbox] (perf/begin-measure "set-view-box") - (view-interaction-start!) + #_(view-interaction-start!) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (perf/end-measure "set-view-box") - (perf/begin-measure "render-from-cache") - (h/call wasm/internal-module "_render_from_cache" 0) - (render-finish) - (perf/end-measure "render-from-cache")) + #_(perf/begin-measure "render-from-cache") + #_(h/call wasm/internal-module "_render_from_cache" 0) + #_(render-finish) + #_(view-interaction-end!) + (internal-render) + #_(perf/end-measure "render-from-cache")) (defn sync-workspace-local-viewport! "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 15f8f39335..febfeaae69 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -11,6 +11,7 @@ build = "build.rs" [features] default = [] stats = [] + profile = ["profile-macros", "profile-raf"] profile-macros = [] profile-raf = [] diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index e02466849a..871f6f520c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; #[allow(unused_imports)] use crate::error::{Error, Result}; -use crate::render::{FrameType, RenderFlag}; +use crate::{render::{FrameType, RenderFlag}, shapes::Frame}; use globals::{get_design_state, get_gpu_state, get_render_state}; @@ -103,10 +103,42 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn render2(timestamp: i32, flags: u8) -> Result { + with_state!(state, { + let render_state = get_render_state(); + + render_state.prepare_render_loop(&mut state.shapes)?; + let frame_type = if flags & RenderFlag::Partial as u8 == RenderFlag::Partial as u8 { + // TODO: Meter una flag que lo que haga es indicar + // si el render es sync o no y que esto no permita + let allow_stop = true; + render_state + .continue_render_loop( + timestamp, + allow_stop + ) + .map_err(|_| Error::RecoverableError("Error rendering".to_string()))? + } else { + let sync_render = false; + render_state.start_render_loop( + timestamp, + sync_render + ).map_err(|_| Error::RecoverableError("Error rendering".to_string()))? + }; + render_state.end_render_loop(&frame_type); + return Ok(frame_type); + }); + +} + +/* #[no_mangle] #[wasm_error] pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { with_state!(state, { + panic!("No debería llamarse"); state.rebuild_touched_tiles(); // Drain the throttled modifier-tile invalidation accumulated // since the previous rAF. set_modifiers skips this work during @@ -133,11 +165,13 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { return Ok(frame_type); }); } +*/ #[no_mangle] #[wasm_error] pub extern "C" fn render_ui_only() -> Result<()> { with_state!(state, { + // panic!("render_ui_only"); state.render_ui_only(); }); Ok(()) @@ -215,21 +249,21 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<() Ok(()) } -#[no_mangle] -#[wasm_error] -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. - // `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 - // render at the new viewport position. - state.render_from_cache(); - }); - Ok(()) -} +// #[no_mangle] +// #[wasm_error] +// 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. +// // `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 +// // render at the new viewport position. +// state.render_from_cache(); +// }); +// Ok(()) +// } #[no_mangle] #[wasm_error] @@ -311,13 +345,13 @@ static mut VIEW_INTERACTION_START: i32 = 0; #[no_mangle] #[wasm_error] pub extern "C" fn set_view_start() -> Result<()> { - #[cfg(feature = "profile-macros")] - unsafe { - VIEW_INTERACTION_START = performance::get_time(); - } - performance::begin_measure!("set_view_start"); - get_render_state().options.set_fast_mode(true); - performance::end_measure!("set_view_start"); + // #[cfg(feature = "profile-macros")] + // unsafe { + // VIEW_INTERACTION_START = performance::get_time(); + // } + // performance::begin_measure!("set_view_start"); + // get_render_state().options.set_fast_mode(true); + // performance::end_measure!("set_view_start"); Ok(()) } @@ -329,32 +363,19 @@ pub extern "C" fn set_view_start() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_view_end() -> Result<()> { - with_state!(state, { - performance::begin_measure!("set_view_end"); - let render_state = get_render_state(); - render_state.options.set_fast_mode(false); - render_state.tile_viewbox.update(&render_state.viewbox); + // with_state!(state, { + // performance::begin_measure!("set_view_end"); + // let render_state = get_render_state(); + // render_state.options.set_fast_mode(false); + // render_state.tile_viewbox.update(&render_state.viewbox); - if render_state.options.is_profile_rebuild_tiles() { - state.rebuild_tiles(); - } else if render_state.zoom_changed() { - // Zoom changed: tile sizes differ so all cached tile - // textures are invalid (wrong scale). Rebuild the tile - // index and clear the tile texture cache, but *preserve* - // the cache canvas so render_from_cache can show a scaled - // preview of the old content while new tiles render. - render_state.rebuild_tile_index(&state.shapes); - render_state.surfaces.invalidate_tile_cache(); - } else { - // Pure pan at the same zoom level: tile contents have not - // changed — only the viewport position moved. Update the - // tile index (which tiles are in the interest area) but - // keep cached tile textures so the render can blit them - // instead of re-drawing every visible tile from scratch. - render_state.rebuild_tile_index(&state.shapes); - } - performance::end_measure!("set_view_end"); - }); + // render_state.rebuild_tile_index(&state.shapes); + // if render_state.viewbox.is_zoom_changed() { + // render_state.surfaces.invalidate_tile_cache(); + // } + + // performance::end_measure!("set_view_end"); + // }); Ok(()) } @@ -368,7 +389,7 @@ pub extern "C" fn set_view_end() -> Result<()> { pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); let render_state = get_render_state(); - render_state.options.set_fast_mode(true); + // render_state.options.set_fast_mode(true); render_state.options.set_interactive_transform(true); performance::end_measure!("set_modifiers_start"); Ok(()) @@ -383,7 +404,7 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { pub extern "C" fn set_modifiers_end() -> Result<()> { performance::begin_measure!("set_modifiers_end"); let render_state = get_render_state(); - render_state.options.set_fast_mode(false); + // render_state.options.set_fast_mode(false); render_state.options.set_interactive_transform(false); performance::end_measure!("set_modifiers_end"); Ok(()) @@ -868,7 +889,7 @@ pub extern "C" fn clean_modifiers() -> Result<()> { // the same tiles for the active modifier set, so the eviction // here is redundant and doubles the per-emission cost. if !prev_modifier_ids.is_empty() && !render_state.options.is_interactive_transform() { - render_state.update_tiles_shapes(&prev_modifier_ids, &mut state.shapes)?; + render_state.update_tiles_shapes(&prev_modifier_ids)?; } }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 159d896fad..afe006f09d 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1,7 +1,5 @@ mod background_blur; -pub mod cache; mod debug; -pub mod drag_crop; pub mod drop_shadow; pub mod enter_exit; pub mod export; @@ -29,25 +27,24 @@ pub mod walk; use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashSet}; use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; -pub use drag_crop::InteractiveDragCrop; pub use focus_mode::FocusMode; pub(crate) use walk::{get_simplified_children, sort_z_index}; pub use walk::{ClipStack, NodeRenderState, RenderStats}; use crate::error::{Error, Result}; +use crate::globals::get_design_state; use crate::shapes::{ - all_with_ancestors, Blur, BlurType, Fill, Layout, Shadow, Shape, Stroke, StrokeKind, - TextContent, Type, + Blur, BlurType, Fill, Frame, Layout, Shadow, Shape, Stroke, StrokeKind, TextContent, Type, all_with_ancestors, }; use crate::state::{RulerState, ShapesPoolMutRef, ShapesPoolRef}; use crate::tiles::{self, PendingTiles, TileRect}; use crate::uuid::Uuid; -use crate::view::Viewbox; +use crate::view::{Viewbox, ViewboxUpdated}; use crate::wapi; use crate::{get_gpu_state, performance}; @@ -69,26 +66,37 @@ pub enum RenderFlag { Full = 2, } +pub struct TileRenderState { + pub current_tile: Option, + /// 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 + /// (must composite to present the work). Reset when current_tile + /// changes. + pub current_tile_had_shapes: bool, + pub tile_viewbox: tiles::TileViewbox, + pub tiles: tiles::TileHashMap, + pub pending_tiles: PendingTiles, +} + pub(crate) struct RenderState { pub options: RenderOptions, stats: RenderStats, pub surfaces: Surfaces, pub fonts: FontStore, pub viewbox: Viewbox, - pub cached_viewbox: Viewbox, + pub base_object: Option, pub images: ImageStore, pub background_color: skia::Color, // Stack of nodes pending to be rendered. pending_nodes: Vec, - pub current_tile: Option, pub sampling_options: skia::SamplingOptions, pub render_area: Rect, // render_area expanded by surface margins — used for visibility checks so that // shapes in the margin zone are rendered (needed for background blur sampling). pub render_area_with_margins: Rect, - pub tile_viewbox: tiles::TileViewbox, - pub tiles: tiles::TileHashMap, - pub pending_tiles: PendingTiles, + pub tile: TileRenderState, // nested_fills maintains a stack of group fills that apply to nested shapes // without their own fill definitions. This is necessary because in SVG, a group's `fill` // can affect its child elements if they don't specify one themselves. If the planned @@ -115,56 +123,14 @@ pub(crate) struct RenderState { /// Preview render mode - when true, uses simplified rendering for progressive loading pub preview_mode: bool, pub export_context: Option<(Rect, f32)>, - /// 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 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 - /// (must composite to present the work). Reset when current_tile - /// changes. - pub current_tile_had_shapes: bool, - /// During interactive transforms we keep `Target` between rAFs. Seed the - /// interactive backdrop exactly once per gesture (first rAF) so we don't - /// repeatedly overwrite tiles that have already been updated. - pub interactive_target_seeded: bool, /// When true, the next `start_render_loop` keeps the last presented `Target` /// pixels instead of clearing the canvas. Set after incremental shape updates /// (e.g. adding a rect) so the workspace stays visible while only affected /// tiles are re-rendered asynchronously. pub preserve_target_during_render: bool, - /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during - /// drag, entries for the moved top-level selection are ensured here - pub backbuffer_crop_cache: HashMap, } impl RenderState { - /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an - /// interactive transform (drag/resize/rotate). - /// - /// We only reuse cached pixels when it is safe and visually correct: - /// - **Top-level only**: cache entries are built for direct children of the root. - /// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew), - /// because other transforms would require resampling and can diverge from the live render. - /// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so - /// we don't show stale content while something moves over/inside it. - fn should_use_cached_top_level_during_interactive( - &mut self, - node_id: Uuid, - tree: ShapesPoolRef, - moved_ids: &[Uuid], - moved_bounds: Option, - ) -> bool { - drag_crop::should_use_cached_top_level_during_interactive( - self, - node_id, - tree, - moved_ids, - moved_bounds, - ) - } - pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let sampling_options = @@ -190,20 +156,23 @@ impl RenderState { surfaces, fonts, viewbox, - cached_viewbox: Viewbox::new(0., 0.), + base_object: None, images: ImageStore::new(), background_color: skia::Color::TRANSPARENT, pending_nodes: vec![], - current_tile: None, sampling_options, render_area: Rect::new_empty(), render_area_with_margins: Rect::new_empty(), - tiles, - tile_viewbox: tiles::TileViewbox::new_with_interest( - &viewbox, - options.dpr_viewport_interest_area_threshold, - ), - pending_tiles: PendingTiles::new(), + tile: TileRenderState { + current_tile: None, + current_tile_had_shapes: false, + tiles, + tile_viewbox: tiles::TileViewbox::new_with_interest( + &viewbox, + options.dpr_viewport_interest_area_threshold, + ), + pending_tiles: PendingTiles::new(), + }, nested_fills: vec![], nested_blurs: vec![], cached_layer_blur: None, @@ -219,11 +188,8 @@ impl RenderState { ignore_nested_blurs: false, preview_mode: false, export_context: None, - cache_cleared_this_render: false, - current_tile_had_shapes: false, - interactive_target_seeded: false, preserve_target_during_render: false, - backbuffer_crop_cache: HashMap::default(), + // backbuffer_crop_cache: HashMap::default(), }) } @@ -301,7 +267,7 @@ impl RenderState { // Only when this function returns true (it means the value // was properly changed) the rest of the functions is called. if self.options.set_dpr(dpr) { - self.tile_viewbox + self.tile.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); self.resize( self.viewbox.width().floor() as i32, @@ -325,7 +291,7 @@ impl RenderState { // The TileViewbox stores its own copy of `interest` (set at // construction). Without propagating, options change wouldn't // affect pending_tiles generation. - self.tile_viewbox + self.tile.tile_viewbox .set_interest(self.options.dpr_viewport_interest_area_threshold); } } @@ -355,15 +321,11 @@ 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.tile.tile_viewbox.update(&self.viewbox); Ok(()) } - pub fn flush(&mut self) { - self.surfaces.flush(SurfaceId::Backbuffer); - } - pub fn flush_and_submit(&mut self) { self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -376,10 +338,10 @@ impl RenderState { // SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent. if self.viewer_masked_pass() { self.surfaces.clear_target(skia::Color::TRANSPARENT); - self.surfaces.copy_backbuffer_to_target_replace(); + self.surfaces.copy_backbuffer_to_target(); } else { self.surfaces - .copy_backbuffer_to_target(self.background_color); + .copy_backbuffer_to_target(); } if self.options.is_debug_visible() { @@ -406,7 +368,9 @@ impl RenderState { /// Blurs the Backbuffer into Target and draws the rulers sharp on top, for /// capturing an already-blurred page-transition snapshot. `blur_radius` is in /// CSS pixels, scaled by DPR to match the device-resolution capture. - pub fn render_blurred_snapshot(&mut self, tree: ShapesPoolRef, blur_radius: f32) { + pub fn render_blurred_snapshot(&mut self, blur_radius: f32) { + let design_state = get_design_state(); + let tree = &design_state.shapes; let sigma = (blur_radius * self.options.dpr).max(0.0); self.surfaces .canvas(SurfaceId::Target) @@ -469,10 +433,6 @@ impl RenderState { self.flush_and_submit(); } - pub fn apply_render_to_final_canvas(&mut self) -> Result<()> { - cache::apply_render_to_final_canvas(self) - } - /// 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"); @@ -616,22 +576,24 @@ impl RenderState { s.canvas().save(); }); } - let fast_mode = self.options.is_fast_mode(); + // let fast_mode = self.options.is_fast_mode(); // Skip anti-aliasing entirely during fast_mode (interactive // gestures + pan/zoom). AA edge sampling is per-pixel and adds // up across many shapes; reverts to full quality on commit. - let antialias = !fast_mode - && shape.should_use_antialias(self.get_scale_fast(), self.options.antialias_threshold); - let skip_effects = fast_mode; + let antialias = true; + // && shape.should_use_antialias(self.get_scale_fast(), self.options.antialias_threshold); + let skip_effects = false; let has_nested_fills = self .nested_fills .last() .is_some_and(|fills| !fills.is_empty()); + let has_inherited_blur = !self.ignore_nested_blurs && self.nested_blurs.iter().flatten().any(|blur| { !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 }); + let can_render_directly = apply_to_current_surface && clip_bounds.is_none() && offset.is_none() @@ -1203,7 +1165,7 @@ impl RenderState { } pub fn update_render_context(&mut self, tile: tiles::Tile) { - self.current_tile = Some(tile); + self.tile.current_tile = Some(tile); let scale = self.get_scale(); self.render_area = tiles::get_tile_rect(tile, scale); let margins = self.surfaces.margins(); @@ -1218,33 +1180,33 @@ impl RenderState { self.surfaces.update_render_context(self.render_area, scale); } - fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { - drag_crop::rebuild_backbuffer_crop_cache(self, tree) - } + // fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { + // drag_crop::rebuild_backbuffer_crop_cache(self, tree) + // } - pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { - cache::render_from_cache(self, shapes) - } + // pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { + // cache::render_from_cache(self, shapes) + // } /// Render a preview of the shapes during loading. /// This rebuilds tiles for touched shapes and renders synchronously. - pub fn render_preview(&mut self, tree: ShapesPoolRef, timestamp: i32) -> Result<()> { + pub fn render_preview(&mut self, timestamp: i32) -> Result<()> { let _start = performance::begin_timed_log!("render_preview"); performance::begin_measure!("render_preview"); // Enable fast_mode during preview to skip expensive effects (blur, shadows). // Restore the previous state afterward so the final render is full quality. - let current_fast_mode = self.options.is_fast_mode(); - self.options.set_fast_mode(true); + // let current_fast_mode = self.options.is_fast_mode(); + // self.options.set_fast_mode(true); // Skip tile rebuilding during preview - we'll do it at the end // Just rebuild tiles for touched shapes and render synchronously - self.rebuild_touched_tiles(tree); + self.rebuild_touched_tiles(); // Use the sync render path - self.start_render_loop(None, tree, timestamp, true)?; + self.start_render_loop(timestamp, true)?; - self.options.set_fast_mode(current_fast_mode); + // self.options.set_fast_mode(current_fast_mode); performance::end_measure!("render_preview"); performance::end_timed_log!("render_preview", _start); @@ -1258,7 +1220,8 @@ impl RenderState { #[cfg(feature = "stats")] self.stats.clear(); - self.surfaces.gc(); + self.surfaces.clear_backbuffer(self.background_color); + self.surfaces.clear_target(self.background_color); self.pending_nodes.clear(); self.pending_nodes.reserve(tree.len()); @@ -1271,7 +1234,7 @@ impl RenderState { self.nested_shadows.clear(); // reorder by distance to the center. - self.current_tile = None; + self.tile.current_tile = None; self.empty_grid_frame_ids.clear(); if self.show_grid.is_some() { @@ -1288,67 +1251,61 @@ impl RenderState { } } + pub fn prepare_render_loop(&mut self, tree: ShapesPoolMutRef) -> Result<()> { + self.rebuild_touched_tiles(); + + // Drain the throttled modifier-tile invalidation accumulated + // since the previous rAF. set_modifiers skips this work during + // interactive_transform; we do it once here, with the current + // modifier set, so the cost is paid once per rAF rather than + // once per pointer move. + if self.options.is_interactive_transform() { + // Collect into an owned Vec to release the immutable borrow on + // `tree` before the mutable `rebuild_modifier_tiles` call. + let ids = tree.modifier_ids().to_vec(); + if !ids.is_empty() { + self.rebuild_modifier_tiles(&ids)?; + } + } + + self.tile.tile_viewbox.update(&self.viewbox); + + self.rebuild_tile_index(); + if self.viewbox.is_updated(ViewboxUpdated::Zoom as u32) { + self.surfaces.invalidate_tile_cache(); + } + Ok(()) + } + + pub fn end_render_loop(&mut self, frame_type: &FrameType) { + match frame_type { + FrameType::Partial => { + self.viewbox.update_handled(); + }, + FrameType::Full => {}, + _ => {} + }; + } + pub fn start_render_loop( &mut self, - base_object: Option<&Uuid>, - tree: ShapesPoolRef, timestamp: i32, sync_render: bool, ) -> Result { + let design_state = get_design_state(); + let tree = &design_state.shapes; self.clear(tree); + view_mode::precompute_viewer_visible_set(self, tree); let _start = performance::begin_timed_log!("start_render_loop"); let scale = self.get_scale(); - self.tile_viewbox.update(&self.viewbox); self.focus_mode.reset(); performance::begin_measure!("render"); performance::begin_measure!("start_render_loop"); - // Compute and set document-space bounds (1 unit == 1 doc px @ 100% zoom) - // 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.atlas.set_doc_bounds(doc_bounds); - - self.cache_cleared_this_render = false; - let preserve_target = self.preserve_target_during_render; - self.preserve_target_during_render = false; - - if self.options.is_interactive_transform() { - // Keep `Target` as the previous frame and overwrite only the tiles - // that changed. This avoids clearing + redrawing an atlas backdrop - // every rAF during drag (a common source of GPU work/stalls). - self.surfaces - .reset_interactive_transform(self.background_color); - if !self.interactive_target_seeded { - // Seed from the last presented frame; this is stable even when - // fast_mode skips cache updates and regardless of atlas coverage. - self.interactive_target_seeded = true; - } - } else if preserve_target || self.zoom_changed() { - // Shape updates or zoom-end: keep the last presented frame on screen - // while tiles are re-rendered asynchronously. During zoom the - // preview from render_from_cache stays visible until the full- - // quality pass completes. - self.surfaces - .reset_interactive_transform(self.background_color); - self.surfaces.seed_backbuffer_from_target(); - self.interactive_target_seeded = false; - } else { - self.reset_canvas(); - self.interactive_target_seeded = false; - // Paint rulers/frame now so they survive the progressive frames - // instead of blanking until the first full `present_frame`. - // Skip on sync renders (thumbnails/exports) - if !sync_render { - ui::render(self, tree); - self.flush_and_submit(); - } - } - // Viewer fixed-scroll passes reuse the same WASM context; `reset` does not // clear Backbuffer, so pass 2 would otherwise keep pass-1 pixels in regions // that render no shapes for the current mask. Target is cleared in present_frame. @@ -1361,57 +1318,32 @@ impl RenderState { | SurfaceId::InnerShadows as u32 | SurfaceId::TextDropShadows as u32; + // NOTE: Why we're scaling in here these surfaces? self.surfaces.apply_mut(surface_ids, |s| { s.canvas().scale((scale, scale)); }); - self.surfaces.resize_cache_from_viewbox( - &self.viewbox, - &self.cached_viewbox, - 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); + self.tile.pending_tiles + .update(&self.tile.tile_viewbox, &self.surfaces, only_visible); performance::end_measure!("tile_cache"); performance::end_timed_log!("tile_cache_update", _tile_start); - self.draw_shape_surface_stack_into(None, SurfaceId::Current); + // self.surfaces.clear_backbuffer(self.background_color); #[allow(unused)] let mut frame_type = FrameType::None; if sync_render { - frame_type = self.render_shape_tree_sync(base_object, tree, timestamp)?; + frame_type = self.render_shape_tree_sync(timestamp)?; } else { // Keep progressive yielding, except for a localized shape edit on a // stable viewbox (e.g. recoloring) which renders in one frame. - let allow_stop = - !preserve_target || self.zoom_changed() || self.options.is_interactive_transform(); - frame_type = self.continue_render_loop(base_object, tree, timestamp, allow_stop)?; - - // 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 - // asynchronously. Without this, panning right after a zoom - // would keep scaling the Cache surface by the old zoom ratio - // (pixelated/wrong-scale tiles) because the async render - // never completes — each pan frame cancels it. - if self.cache_cleared_this_render { - self.cached_viewbox = self.viewbox; - } + let allow_stop = true; // self.zoom_changed() || self.options.is_interactive_transform(); + frame_type = self.continue_render_loop(timestamp, allow_stop)?; } performance::end_measure!("start_render_loop"); @@ -1419,77 +1351,39 @@ impl RenderState { Ok(frame_type) } - fn compute_document_bounds( - &mut self, - base_object: Option<&Uuid>, - tree: ShapesPoolRef, - ) -> Option { - let ids: Vec = if let Some(id) = base_object { - vec![*id] - } else { - let root = tree.get(&Uuid::nil())?; - root.children_ids(false) - }; - - let mut acc: Option = None; - for id in ids.iter() { - let Some(shape) = tree.get(id) else { - continue; - }; - let r = shape.extrect(tree, 1.0); - if r.is_empty() { - continue; - } - acc = Some(if let Some(mut a) = acc { - a.join(r); - a - } else { - r - }); - } - acc - } - pub fn continue_render_loop( &mut self, - base_object: Option<&Uuid>, - tree: ShapesPoolRef, timestamp: i32, allow_stop: bool, ) -> Result { + let design_state = get_design_state(); + let tree = &design_state.shapes; performance::begin_measure!("continue_render_loop"); + // println!("continue_render_loop {:p} {:p}", + // std::ptr::addr_of!(self.base_object), + // std::ptr::addr_of!(tree), + // ); let frame_type = - self.render_shape_tree_partial(base_object, tree, timestamp, allow_stop)?; + self.render_shape_tree_tiled( tree, timestamp, allow_stop)?; // `draw_atlas` needs a snapshot of the tile atlas. Partial frames are not // presented (only flushed), so defer composition to the final frame and // avoid re-snapshotting up to 4096² on every rAF during async tile work. - if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { + // if !self.options.is_interactive_transform() && matches!(frame_type, FrameType::Full) { self.surfaces.draw_tile_atlas_to_backbuffer( &self.viewbox, - &self.tile_viewbox, - self.background_color, + &self.tile.tile_viewbox ); - } + // } 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(); + self.present_frame(tree); } 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"); @@ -1501,19 +1395,18 @@ impl RenderState { pub fn render_shape_tree_sync( &mut self, - base_object: Option<&Uuid>, - tree: ShapesPoolRef, timestamp: i32, ) -> Result { - self.render_shape_tree_partial(base_object, tree, timestamp, false)?; + let design_state = get_design_state(); + let tree = &design_state.shapes; + self.render_shape_tree_tiled(tree, timestamp, false)?; // Same composition as `continue_render_loop` for full frames: snapshot only the // drawable tile rect into the atlas (no blur-margin overlap), then blit once. if !self.viewer_masked_pass() { self.surfaces.draw_tile_atlas_to_backbuffer( &self.viewbox, - &self.tile_viewbox, - self.background_color, + &self.tile.tile_viewbox ); } @@ -1535,10 +1428,7 @@ impl RenderState { } #[inline] - pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool { - if iteration % self.options.node_batch_threshold != 0 { - return false; - } + pub fn should_stop_rendering(&self, timestamp: i32) -> bool { if performance::get_time() - timestamp <= self.options.max_blocking_time_ms { return false; } @@ -1548,8 +1438,8 @@ impl RenderState { // popping in sequentially. Only yield once all visible work is // done and we are processing the interest-area pre-render. if self.options.is_interactive_transform() { - if let Some(tile) = self.current_tile { - if self.tile_viewbox.is_visible(&tile) { + if let Some(tile) = self.tile.current_tile { + if self.tile.tile_viewbox.is_visible(&tile) { return false; } } @@ -1619,14 +1509,6 @@ impl RenderState { enter_exit::render_shape_exit(self, element, visited_mask, clip_bounds, target_surface) } - pub fn get_current_tile_bounds(&mut self) -> Result { - let tile = self - .current_tile - .ok_or(Error::CriticalError("Current tile not found".to_string()))?; - let offset = self.viewbox.get_offset(); - Ok(tile.get_rect_with_offset(&offset)) - } - pub fn get_rect_bounds(&mut self, rect: skia::Rect) -> Rect { let scale = self.get_scale(); let offset_x = self.viewbox.area.left * scale; @@ -1674,7 +1556,7 @@ impl RenderState { /// consistent and predictable layout. pub fn get_current_aligned_tile_bounds(&mut self) -> Result { Ok(self.get_aligned_tile_bounds( - self.current_tile + self.tile.current_tile .ok_or(Error::CriticalError("Current tile not found".to_string()))?, )) } @@ -1704,14 +1586,13 @@ impl RenderState { ) } - pub fn render_shape_tree_partial_uncached( + pub fn render_shape_tree_tile( &mut self, tree: ShapesPoolRef, timestamp: i32, allow_stop: bool, export: bool, ) -> Result<(bool, bool)> { - let mut iteration = 0; let mut is_empty = true; let mut target_surface = SurfaceId::Current; @@ -1719,48 +1600,6 @@ impl RenderState { target_surface = SurfaceId::Export; } - // During interactive transforms we compute the union of the current bounds of all - // modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap - // guard to decide when cached top-level crops are unsafe to reuse (something is moving - // over/inside them), without doing expensive ancestor walks per node. - // - // `modifier_ids` is pre-computed once here and reused throughout the loop to avoid - // repeated allocations (formerly O(N_shapes) HashMap builds) per node. - let modifier_ids = tree.modifier_ids(); - let moved_bounds = if self.options.is_interactive_transform() && !modifier_ids.is_empty() { - let mut acc: Option = None; - for id in modifier_ids.iter() { - // Current (post-modifier) bounds - if let Some(s) = tree.get(id) { - let r = s.extrect(tree, 1.0); - acc = Some(match acc { - None => r, - Some(mut prev) => { - prev.join(r); - prev - } - }); - } - - // Pre-modifier bounds: important so cached top-level crops that still contain the - // shape at its original position are considered "unsafe" even after the shape - // has moved away (e.g. dragging a child out of a clipped frame). - if let Some(raw) = tree.get_raw(id) { - let r0 = raw.extrect(tree, 1.0); - acc = Some(match acc { - None => r0, - Some(mut prev) => { - prev.join(r0); - prev - } - }); - } - } - acc - } else { - None - }; - while let Some(node_render_state) = self.pending_nodes.pop() { let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; @@ -1779,7 +1618,7 @@ impl RenderState { let mut extrect: Option = None; // If the shape is not in the tile set, then we add them. - if self.tiles.get_tiles_of(node_id).is_none() { + if self.tile.tiles.get_tiles_of(node_id).is_none() { self.add_shape_tiles(element, tree); } @@ -1869,71 +1708,6 @@ impl RenderState { } } - // Interactive drag cache: if this node is cacheable during interactive transform, - // draw it directly from Backbuffer crop on the current tile surface and skip - // traversing/rendering the subtree. - if self.options.is_interactive_transform() { - let use_cached = self.should_use_cached_top_level_during_interactive( - node_id, - tree, - modifier_ids, - moved_bounds, - ); - - if use_cached { - if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { - let crop_image = &crop.image; - let crop_src_selrect = crop.src_selrect; - - let cur_selrect = tree.get(&node_id).map(|s| s.selrect()); - let (dx, dy) = match cur_selrect { - Some(cur) => ( - cur.left - crop_src_selrect.left, - cur.top - crop_src_selrect.top, - ), - None => (0.0, 0.0), - }; - let scale = self.get_scale_fast(); - let translation = self - .surfaces - .get_render_context_translation(self.render_area, scale); - - let canvas = self.surfaces.canvas(target_surface); - canvas.save(); - canvas.reset_matrix(); - // If the crop includes shadows/blur (extrect pixels outside the fill/stroke - // silhouette), do NOT apply the silhouette clip or we'd cut those pixels. - let should_clip_crop = element.shadows.is_empty() && element.blur.is_none(); - if should_clip_crop { - if let Some(clip_path) = element.drag_crop_clip_path() { - let mut doc_to_tile = Matrix::new_identity(); - // Map document-space coordinates into tile pixels. - // Rendering surfaces apply: scale(scale) then translate(translation) in doc units. - // Equivalent point mapping: (doc + translation) * scale. - doc_to_tile.post_translate((translation.0, translation.1)); - doc_to_tile.post_scale((scale, scale), None); - let clip_path = clip_path.make_transform(&doc_to_tile); - canvas.clip_path(&clip_path, skia::ClipOp::Intersect, true); - } - } - let doc_left = - crop.capture_vb_left + (crop.capture_src_left as f32 / scale) + dx; - let doc_top = - crop.capture_vb_top + (crop.capture_src_top as f32 / scale) + dy; - - let x = (doc_left + translation.0) * scale; - let y = (doc_top + translation.1) * scale; - let bw = crop_image.width() as f32; - let bh = crop_image.height() as f32; - let dst = skia::Rect::from_xywh(x, y, bw, bh); - canvas.draw_image_rect(crop_image, None, dst, &skia::Paint::default()); - - canvas.restore(); - } - continue; - } - } - let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id); // Skip render_shape_enter/exit for flattened containers @@ -1947,7 +1721,6 @@ impl RenderState { // the layer blur (which would make it more diffused than without clipping) let shadow_before_layer = !node_render_state.is_root() && self.focus_mode.is_active() - && !self.options.is_fast_mode() && !matches!(element.shape_type, Type::Text(_)) && Self::frame_clip_layer_blur(element).is_some() && element.drop_shadows_visible().next().is_some(); @@ -1975,8 +1748,8 @@ impl RenderState { if !node_render_state.is_root() && self.focus_mode.is_active() { // Skip expensive drop shadow rendering in fast mode (during pan/zoom). - let skip_shadows = self.options.is_fast_mode(); - + // let skip_shadows = self.options.is_fast_mode(); + let skip_shadows = false; // Skip shadow block when already rendered before the layer (frame_clip_layer_blur) let shadows_already_rendered = Self::frame_clip_layer_blur(element).is_some(); @@ -2103,27 +1876,31 @@ impl RenderState { } // We try to avoid doing too many calls to get_time - if allow_stop && self.should_stop_rendering(iteration, timestamp) { + if allow_stop && self.should_stop_rendering(timestamp) { return Ok((is_empty, true)); } - iteration += 1; } Ok((is_empty, false)) } - pub fn render_shape_tree_partial( + pub fn render_shape_tree_tiled( &mut self, - base_object: Option<&Uuid>, tree: ShapesPoolRef, timestamp: i32, allow_stop: bool, ) -> Result { + // println!("render_shape_tree_tiled {:p} {:p}", + // std::ptr::addr_of!(self.base_object), + // std::ptr::addr_of!(tree), + // ); let mut should_stop = false; - self.viewer_render_root = base_object.copied(); - let root_ids = { - if let Some(shape_id) = base_object { - vec![*shape_id] + + self.viewer_render_root = self.base_object; + + let root_ids: Vec = { + if let Some(shape_id) = self.base_object { + vec![shape_id] } else { let Some(root) = tree.get(&Uuid::nil()) else { return Err(Error::CriticalError("Root shape not found".to_string())); @@ -2133,7 +1910,8 @@ impl RenderState { }; while !should_stop { - if let Some(current_tile) = self.current_tile { + if let Some(current_tile) = self.tile.current_tile { + // println!("current_tile {:?}", current_tile); // 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. // Viewer masked passes (include_filter) must not reuse cached tiles from @@ -2142,12 +1920,7 @@ impl RenderState { { 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); - } + .render_shape_tree_tile(tree, timestamp, allow_stop, false)?; if early_return { self.viewer_render_root = None; @@ -2155,23 +1928,22 @@ impl RenderState { } performance::end_measure!("render_shape_tree::uncached"); - let tile_rect = self.get_current_tile_bounds()?; // Composite if the walker did work in this PAF (`!is_empty`) OR // the tile has unfinished work from a previous PAF // (`current_tile_had_shapes` was set when we populated pending_nodes // for this tile). - if !is_empty || self.current_tile_had_shapes { - if self.options.is_interactive_transform() { - // During drag, avoid snapshot-based caching. Draw Current directly - // into Target (and Cache) to reduce stalls. - self.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - self.background_color, - surfaces::DrawOnCache::Yes, - ); - } else { - self.apply_render_to_final_canvas()?; - } + if !is_empty || self.tile.current_tile_had_shapes { + let tile_rect = self.get_current_aligned_tile_bounds()?; + + let current_tile = *self + .tile + .current_tile + .as_ref() + .ok_or(Error::CriticalError("Current tile not found".to_string()))?; + + self.surfaces.draw_current_tile_into_tile_atlas( + ¤t_tile, + ); if self.options.is_debug_visible() { debug::render_workspace_current_tile( @@ -2182,7 +1954,8 @@ impl RenderState { ); } } - } else if self.tiles.is_empty_at(current_tile) { + } else if self.tile.tiles.is_empty_at(current_tile) { + // println!("self.tile.tiles.is_empty_at({:?}", current_tile); self.surfaces.remove_cached_tile_surface(current_tile); } } @@ -2193,17 +1966,17 @@ impl RenderState { // If we finish processing every node rendering is complete // let's check if there are more pending nodes - if let Some(next_tile) = self.pending_tiles.pop() { + if let Some(next_tile) = self.tile.pending_tiles.pop() { self.update_render_context(next_tile); // Reset for the new tile. We'll flip it to true if the // tile has shapes, so a later "is_empty=true" reflects // a resumed-from-yield case rather than a genuinely // empty tile. - self.current_tile_had_shapes = false; + self.tile.current_tile_had_shapes = false; let viewer_masked_pass = self.viewer_masked_pass(); - let Some(ids) = self.tiles.get_shapes_at(next_tile) else { + let Some(ids) = self.tile.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. continue; }; @@ -2232,7 +2005,8 @@ impl RenderState { // 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 { + 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() { @@ -2243,7 +2017,7 @@ impl RenderState { } if !valid_ids.is_empty() { - self.current_tile_had_shapes = true; + self.tile.current_tile_had_shapes = true; } self.pending_nodes @@ -2259,22 +2033,11 @@ impl RenderState { // If there are no more pending tiles, stop. should_stop = true; } + } self.viewer_render_root = None; - // Mark cache as valid for render_from_cache. - // Only update for full-quality renders (non-fast mode). - // An async render can complete while fast mode is active - // (e.g. interest-area tiles finish during a pan gesture). - // Those tiles lack effects (shadows, blur). Updating - // cached_viewbox here would make zoom_changed() return false, - // so set_view_end would skip tile invalidation and the next - // full render would reuse the low-quality tiles. - if !self.options.is_fast_mode() { - self.cached_viewbox = self.viewbox; - } - Ok(FrameType::Full) } @@ -2289,12 +2052,14 @@ impl RenderState { * are dynamically added to the tile index via the fallback mechanism in * render_shape_tree_partial_uncached, ensuring all shapes render correctly. */ - pub fn get_tiles_for_shape(&mut self, shape: &Shape, tree: ShapesPoolRef) -> TileRect { + pub fn get_tiles_for_shape(&mut self, shape: &Shape) -> TileRect { + let design_state = get_design_state(); + let tree = &design_state.shapes; let scale = self.get_scale(); let extrect = shape.extrect(tree, scale); let tile_size = tiles::get_tile_size(scale); let shape_tiles = tiles::get_tiles_for_rect(extrect, tile_size); - let interest_rect = &self.tile_viewbox.interest_rect; + let interest_rect = &self.tile.tile_viewbox.interest_rect; // Calculate the intersection of shape_tiles with interest_rect // This returns only the tiles that are both in the shape and in the interest area let intersection_x1 = shape_tiles.x1().max(interest_rect.x1()); @@ -2326,48 +2091,27 @@ impl RenderState { pub fn update_shape_tiles( &mut self, shape: &Shape, - tree: ShapesPoolRef, ) -> HashSet { - let tile_rect = self.get_tiles_for_shape(shape, tree); + let tile_rect = self.get_tiles_for_shape(shape); // Collect old tiles to avoid borrow conflict with remove_shape_at let old_tiles: Vec<_> = self + .tile .tiles .get_tiles_of(shape.id) .map_or(Vec::new(), |t| t.iter().copied().collect()); let mut result = HashSet::::with_capacity(old_tiles.len()); - // 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` - // 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 - // change. Clearing the full raw extrect here removes all such residual - // content without growing the atlas. - // - // We intentionally skip this when there is NO modifier so that plain - // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. - 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 - .atlas - .clear_doc_rect_in_atlas_clipped(old_extrect); - } - } - // First, remove the shape from all tiles where it was previously located for tile in old_tiles { - self.tiles.remove_shape_at(tile, shape.id); + self.tile.tiles.remove_shape_at(tile, shape.id); result.insert(tile); } // Then, add the shape to the new tiles for tile in tile_rect.iter(true) { - self.tiles.add_shape_at(tile, shape.id); + self.tile.tiles.add_shape_at(tile, shape.id); result.insert(tile); } @@ -2393,10 +2137,10 @@ impl RenderState { pub fn update_shape_tiles_incremental( &mut self, shape: &Shape, - tree: ShapesPoolRef, ) -> Vec { - let tile_rect = self.get_tiles_for_shape(shape, tree); + let tile_rect = self.get_tiles_for_shape(shape); let old_tiles: HashSet = self + .tile .tiles .get_tiles_of(shape.id) .map_or(HashSet::new(), |tiles| tiles.iter().copied().collect()); @@ -2410,12 +2154,12 @@ impl RenderState { // Update the index: remove from old tiles for tile in &removed { - self.tiles.remove_shape_at(*tile, shape.id); + self.tile.tiles.remove_shape_at(*tile, shape.id); } // Update the index: add to new tiles for tile in &added { - self.tiles.add_shape_at(*tile, shape.id); + self.tile.tiles.add_shape_at(*tile, shape.id); } // Don't invalidate cache for pan/zoom - the tile content hasn't changed, @@ -2431,9 +2175,9 @@ impl RenderState { */ pub fn add_shape_tiles(&mut self, shape: &Shape, tree: ShapesPoolRef) -> Vec { performance::begin_measure!("add_shape_tiles"); - let tiles: Vec = self.get_tiles_for_shape(shape, tree).iter(true).collect(); + let tiles: Vec = self.get_tiles_for_shape(shape).iter(true).collect(); for tile in tiles.iter() { - self.tiles.add_shape_at(*tile, shape.id); + self.tile.tiles.add_shape_at(*tile, shape.id); } performance::end_measure!("add_shape_tiles"); tiles @@ -2446,7 +2190,10 @@ impl RenderState { /// Rebuild the tile index (shape→tile mapping) for all top-level shapes. /// This does NOT invalidate the tile texture cache — cached tile images /// survive so that fast-mode renders during pan still show shadows/blur. - pub fn rebuild_tile_index(&mut self, tree: ShapesPoolRef) { + pub fn rebuild_tile_index(&mut self) { + let design_state = get_design_state(); + let tree = &design_state.shapes; + let zoom_changed = self.zoom_changed(); performance::begin_measure!("rebuild_tile_index"); let mut nodes = Vec::::with_capacity(64); @@ -2455,9 +2202,9 @@ impl RenderState { if let Some(shape) = tree.get(&shape_id) { if shape_id != Uuid::nil() { if zoom_changed { - let _ = self.update_shape_tiles(shape, tree); + let _ = self.update_shape_tiles(shape); } else { - let _ = self.update_shape_tiles_incremental(shape, tree); + let _ = self.update_shape_tiles_incremental(shape); } } else { // We only need to rebuild tiles from the first level. @@ -2470,17 +2217,15 @@ impl RenderState { performance::end_measure!("rebuild_tile_index"); } - pub fn rebuild_tiles_shallow(&mut self, tree: ShapesPoolRef) { + pub fn rebuild_tiles_shallow(&mut self) { performance::begin_measure!("rebuild_tiles_shallow"); - self.rebuild_tile_index(tree); + self.rebuild_tile_index(); // Zoom changes world tile size: a partial cache update would mix scales in the // mosaic and glitch. Same zoom as last finished render (typical pan): drop only // tile textures and keep the cache canvas for render_from_cache. - if self.zoom_changed() { - self.surfaces.remove_cached_tiles(self.background_color); - } else { + if !self.zoom_changed() { self.surfaces.invalidate_tile_cache(); } @@ -2490,7 +2235,7 @@ impl RenderState { pub fn rebuild_tiles_from(&mut self, tree: ShapesPoolRef, base_id: Option<&Uuid>) { performance::begin_measure!("rebuild_tiles"); - self.tiles.invalidate(); + self.tile.tiles.invalidate(); let mut all_tiles = HashSet::::new(); let mut nodes = { @@ -2515,7 +2260,6 @@ impl RenderState { } // Invalidate changed tiles - old content stays visible until new tiles render - self.surfaces.remove_cached_tiles(self.background_color); for tile in all_tiles { self.remove_cached_tile(tile); } @@ -2526,9 +2270,12 @@ impl RenderState { * Rebuild the tiles for the shapes that have been modified from the * last time this was executed. */ - pub fn rebuild_touched_tiles(&mut self, tree: ShapesPoolRef) { + pub fn rebuild_touched_tiles(&mut self) { performance::begin_measure!("rebuild_touched_tiles"); + let design_state = get_design_state(); + let tree = &design_state.shapes; + let mut all_tiles = HashSet::::new(); let ids = std::mem::take(&mut self.touched_ids); @@ -2537,7 +2284,7 @@ impl RenderState { for shape_id in ids.iter() { if let Some(shape) = tree.get(shape_id) { if shape_id != &Uuid::nil() { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + all_tiles.extend(self.update_shape_tiles(shape)); } } } @@ -2561,13 +2308,14 @@ impl RenderState { pub fn update_tiles_shapes( &mut self, shape_ids: &[Uuid], - tree: ShapesPoolMutRef<'_>, ) -> Result<()> { performance::begin_measure!("invalidate_and_update_tiles"); + let design_state = get_design_state(); + let tree = &design_state.shapes; let mut all_tiles = HashSet::::new(); for shape_id in shape_ids { if let Some(shape) = tree.get(shape_id) { - all_tiles.extend(self.update_shape_tiles(shape, tree)); + all_tiles.extend(self.update_shape_tiles(shape)); } } for tile in all_tiles { @@ -2585,7 +2333,6 @@ impl RenderState { /// This is crucial for frames and groups that contain transformed children. pub fn rebuild_modifier_tiles( &mut self, - tree: ShapesPoolMutRef<'_>, ids: &[Uuid], ) -> Result<()> { // During interactive transform, skip ancestor invalidation: walking up to the @@ -2594,10 +2341,12 @@ impl RenderState { // `ShapesPool::set_modifiers`; the tile index is reconciled post-gesture by // the committing code path (rebuild_touched_tiles). if self.options.is_interactive_transform() { - self.update_tiles_shapes(ids, tree)?; + self.update_tiles_shapes(ids)?; } else { + let design_state = get_design_state(); + let tree = &design_state.shapes; let ancestors = all_with_ancestors(ids, tree, false); - self.update_tiles_shapes(&ancestors, tree)?; + self.update_tiles_shapes(&ancestors)?; } Ok(()) } @@ -2618,7 +2367,7 @@ impl RenderState { } pub fn zoom_changed(&self) -> bool { - (self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON + self.viewbox.is_zoom_changed() } pub fn mark_touched(&mut self, uuid: Uuid) { @@ -2640,7 +2389,7 @@ impl RenderState { pub fn prepare_context_loss_cleanup(&mut self) { // Drop cached GPU-backed snapshots before dropping the render state. - self.backbuffer_crop_cache.clear(); + // self.backbuffer_crop_cache.clear(); self.surfaces.invalidate_tile_cache(); // Mark context as abandoned so resource destructors avoid issuing // GL commands when the browser has already lost/restored the context. diff --git a/render-wasm/src/render/background_blur.rs b/render-wasm/src/render/background_blur.rs index f811c3efa0..8da23f725f 100644 --- a/render-wasm/src/render/background_blur.rs +++ b/render-wasm/src/render/background_blur.rs @@ -7,9 +7,12 @@ pub fn render_background_blur( shape: &Shape, target_surface: SurfaceId, ) { + /* if render_state.options.is_fast_mode() { return; } + */ + if matches!(shape.shape_type, Type::Text(_)) || matches!(shape.shape_type, Type::SVGRaw(_)) { return; } diff --git a/render-wasm/src/render/cache.rs b/render-wasm/src/render/cache.rs deleted file mode 100644 index 586c56e3c5..0000000000 --- a/render-wasm/src/render/cache.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::error::{Error, Result}; -use crate::state::ShapesPoolRef; -use crate::tiles; - -pub fn apply_render_to_final_canvas(render_state: &mut crate::render::RenderState) -> 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 render_state.options.is_interactive_transform() { - let tile_rect = render_state.get_current_aligned_tile_bounds()?; - render_state.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - render_state.background_color, - surfaces::DrawOnCache::No, - ); - return Ok(()); - } - - // Viewer masked passes render a partial scene. Reusing the tile texture cache would - // SrcOver-blend onto textures from the previous pass and leak pixels into the blob. - if render_state.viewer_masked_pass() { - // Use viewbox-aligned bounds (not grid-snapped) to match interactive-transform - // compositing and avoid a visible offset vs the DOM canvas. - let tile_rect = render_state.get_current_tile_bounds()?; - render_state.surfaces.draw_current_tile_into_backbuffer( - &tile_rect, - render_state.background_color, - surfaces::DrawOnCache::No, - ); - return Ok(()); - } - - let fast_mode = render_state.options.is_fast_mode(); - // Decide *now* (at the first real cache blit) whether we need to clear Cache. - // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), - // while still preventing stale pixels from surviving across full-quality renders. - if !fast_mode && !render_state.cache_cleared_this_render { - render_state.surfaces.clear_cache(render_state.background_color); - render_state.cache_cleared_this_render = true; - } - // In fast mode the viewport is moving (pan/zoom) so Cache surface - // positions would be wrong — only save to the tile HashMap. - let tile_rect = render_state.get_current_aligned_tile_bounds()?; - - let current_tile = *render_state - .current_tile - .as_ref() - .ok_or(Error::CriticalError("Current tile not found".to_string()))?; - - render_state.surfaces.draw_current_tile_into_tile_atlas( - &render_state.tile_viewbox, - ¤t_tile, - &tile_rect, - fast_mode, - render_state.render_area, - ); - - Ok(()) -} - -pub fn render_from_cache(render_state: &mut crate::render::RenderState, shapes: ShapesPoolRef) { - let _start = performance::begin_timed_log!("render_from_cache"); - performance::begin_measure!("render_from_cache"); - let bg_color = render_state.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 render_state.options.is_fast_mode() && !render_state.surfaces.atlas.is_empty() { - render_state.surfaces - .draw_atlas_to_backbuffer(render_state.viewbox, bg_color); - - render_state.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - - // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) - if render_state.cached_viewbox.area.width() > 0.0 { - // Scale and translate the target according to the cached data - let navigate_zoom = render_state.viewbox.zoom / render_state.cached_viewbox.zoom; - - let interest = render_state.options.dpr_viewport_interest_area_threshold; - let TileRect(start_tile_x, start_tile_y, _, _) = - tiles::get_tiles_for_viewbox_with_interest(&render_state.cached_viewbox, interest); - let offset_x = render_state.viewbox.area.left * render_state.cached_viewbox.zoom * render_state.options.dpr; - let offset_y = render_state.viewbox.area.top * render_state.cached_viewbox.zoom * render_state.options.dpr; - let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; - let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - - // For zoom-out, prefer cache only if it fully covers the viewport. - // Otherwise, atlas will provide a more correct full-viewport preview. - let zooming_out = render_state.viewbox.zoom < render_state.cached_viewbox.zoom; - if zooming_out { - let cache_dim = render_state.surfaces.cache_dimensions(); - let cache_w = cache_dim.width as f32; - let cache_h = cache_dim.height as f32; - - // Viewport in target pixels. - let vw = render_state.viewbox.dpr_width().max(1.0); - let vh = render_state.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). - // => cache = (target / navigate_zoom) - translate - let inv = if navigate_zoom.abs() > f32::EPSILON { - 1.0 / navigate_zoom - } else { - 0.0 - }; - - // 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; - - let min_x = cx0.min(cx1); - let min_y = cy0.min(cy1); - let max_x = cx0.max(cx1); - let max_y = cy0.max(cy1); - - let cache_covers = - 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 !render_state.surfaces.atlas.is_empty() { - render_state.surfaces - .draw_atlas_to_backbuffer(render_state.viewbox, bg_color); - - render_state.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - } - } - - // Draw directly from cache surface, avoiding snapshot overhead - render_state.surfaces.draw_cache_to_backbuffer(); - - render_state.present_frame(shapes); - } - - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); -} diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 893778b4ec..fe9bde42b0 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -36,16 +36,6 @@ fn render_debug_view(render_state: &mut RenderState) { .draw_rect(rect, &paint); } -pub fn render_debug_cache_surface(render_state: &mut RenderState) { - let canvas = render_state.surfaces.canvas(SurfaceId::Debug); - canvas.save(); - canvas.scale((0.1, 0.1)); - render_state - .surfaces - .draw_into(SurfaceId::Cache, SurfaceId::Debug, None); - render_state.surfaces.canvas(SurfaceId::Debug).restore(); -} - pub fn render_wasm_label(render_state: &mut RenderState) { if render_state.preview_mode || !render_state.options.show_wasm_info() { return; @@ -79,7 +69,7 @@ pub fn render_wasm_label(render_state: &mut RenderState) { } 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 tiles::TileRect(sx, sy, ex, ey) = render_state.tile.tile_viewbox.interest_rect; let canvas = render_state.surfaces.canvas(SurfaceId::Debug); let mut paint = skia::Paint::default(); paint.set_color(skia::Color::RED); @@ -264,48 +254,6 @@ 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")] -pub extern "C" fn debug_cache_console() -> Result<()> { - console_debug_surface(get_render_state(), SurfaceId::Cache); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -#[cfg(target_arch = "wasm32")] -pub extern "C" fn debug_cache_base64() -> Result<()> { - console_debug_surface_base64(get_render_state(), SurfaceId::Cache); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -#[cfg(target_arch = "wasm32")] -pub extern "C" fn debug_atlas_console() -> Result<()> { - console_debug_surface(get_render_state(), SurfaceId::Atlas); - Ok(()) -} - -#[no_mangle] -#[wasm_error] -#[cfg(target_arch = "wasm32")] -pub extern "C" fn debug_atlas_base64() -> Result<()> { - console_debug_surface_base64(get_render_state(), SurfaceId::Atlas); - Ok(()) -} - #[no_mangle] #[wasm_error] #[cfg(target_arch = "wasm32")] diff --git a/render-wasm/src/render/drag_crop.rs b/render-wasm/src/render/drag_crop.rs deleted file mode 100644 index e91de28455..0000000000 --- a/render-wasm/src/render/drag_crop.rs +++ /dev/null @@ -1,330 +0,0 @@ -use skia_safe::{self as skia, Rect}; - -use super::RenderState; -use super::SurfaceId; -use crate::get_gpu_state; -use crate::math; -use crate::state::ShapesPoolRef; -use crate::uuid::Uuid; - -pub struct InteractiveDragCrop { - pub src_doc_bounds: Rect, - pub src_selrect: Rect, - /// Viewbox origin (doc-space) at capture time. - pub capture_vb_left: f32, - pub capture_vb_top: f32, - /// Backbuffer pixel origin used for `snapshot_rect` (so we can do 1:1 blits). - pub capture_src_left: i32, - pub capture_src_top: i32, - pub image: skia::Image, -} - -/// Chooses a window inside the full workspace-pixel crop `[0, out_w) × [0, out_h)` with each side -/// at most `max_side_px` (**without scaling**): centered on the projection of -/// `viewport_doc ∩ src_doc_bounds`, or on the full crop if that intersection is empty. -/// `max_side_px` should match [`GpuState::max_texture_size`] (same budget as the atlas). -#[allow(clippy::too_many_arguments)] -pub fn drag_crop_snapshot_window_px( - max_side_px: i32, - out_w: i32, - out_h: i32, - viewport_doc: Rect, - vb_left: f32, - vb_top: f32, - scale: f32, - src_left_px: i32, - src_top_px: i32, - src_doc_bounds: Rect, -) -> (i32, i32, i32, i32) { - let cap = max_side_px.max(1); - if out_w <= cap && out_h <= cap { - return (0, 0, out_w, out_h); - } - let win_w = out_w.min(cap); - let win_h = out_h.min(cap); - - let mut vis = viewport_doc; - let has_vis = vis.intersect(src_doc_bounds); - let (cx, cy) = if !has_vis || vis.is_empty() { - (out_w as f32 * 0.5, out_h as f32 * 0.5) - } else { - let lx0 = (vis.left - vb_left) * scale - src_left_px as f32; - let ly0 = (vis.top - vb_top) * scale - src_top_px as f32; - let lx1 = (vis.right - vb_left) * scale - src_left_px as f32; - let ly1 = (vis.bottom - vb_top) * scale - src_top_px as f32; - ((lx0 + lx1) * 0.5, (ly0 + ly1) * 0.5) - }; - - let mut ox = (cx - win_w as f32 * 0.5).round() as i32; - let mut oy = (cy - win_h as f32 * 0.5).round() as i32; - ox = ox.clamp(0, out_w - win_w); - oy = oy.clamp(0, out_h - win_h); - (ox, oy, win_w, win_h) -} - -/// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an -/// interactive transform (drag/resize/rotate). -/// -/// We only reuse cached pixels when it is safe and visually correct: -/// - **Top-level only**: cache entries are built for direct children of the root. -/// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew), -/// because other transforms would require resampling and can diverge from the live render. -/// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so -/// we don't show stale content while something moves over/inside it. -pub fn should_use_cached_top_level_during_interactive( - render_state: &mut RenderState, - node_id: Uuid, - tree: ShapesPoolRef, - moved_ids: &[Uuid], - moved_bounds: Option, -) -> bool { - if !render_state.backbuffer_crop_cache.contains_key(&node_id) { - return false; - } - let Some(raw) = tree.get_raw(&node_id) else { - return false; - }; - if raw.parent_id != Some(Uuid::nil()) { - return false; - } - - // If this top-level shape itself is being moved, always allow using its cached pixels. - // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), - // cached pixels won't match the live result (and may require resampling), so render live. - if moved_ids.contains(&node_id) { - let Some(m) = tree.get_modifier(&node_id) else { - return false; - }; - // Only allow using the cached pixels for pure translations. - // For non-translation transforms (scale/rotate/skew), cached pixels won't match. - // If the transform is the identity means a reflow, we need to redraw as well. - if math::identitish(m) || !math::is_move_only_matrix(m) { - return false; - } - - // Additionally require this node to be safe to serve from a rectangular backbuffer - // crop while moving; otherwise it must be rendered live (e.g. text, overflow frames). - return tree - .get(&node_id) - .is_some_and(|s| s.is_safe_for_drag_crop_cache(tree)); - } - - // If the moving content overlaps this cached crop, do not use the cached pixels - // for this frame. We intentionally keep the cache entry: overlap is typically - // transient during drag, and once the moving content leaves the area the crop - // becomes valid again (stationary shape unchanged). - if let Some(moved) = moved_bounds { - let intersects = render_state - .backbuffer_crop_cache - .get(&node_id) - .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); - - if intersects { - return false; - } - } - true -} - -pub fn rebuild_backbuffer_crop_cache(render_state: &mut RenderState, tree: ShapesPoolRef) { - render_state.backbuffer_crop_cache.clear(); - - // Collect candidate shapes that are "recortable" and visible in the current viewport. - - // This is intentionally conservative; we only cache shapes that do not overlap with - // ANY other candidate to guarantee the pixels under their bounds belong exclusively - // to that shape in Backbuffer. - let viewport = render_state.viewbox.area; - let scale = render_state.get_scale(); - let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) - - let root_ids: Vec = match tree.get(&Uuid::nil()) { - Some(root) => root.children_ids(false), - None => Vec::new(), - }; - - for shape_id in root_ids { - let Some(shape) = tree.get(&shape_id) else { - continue; - }; - if shape.hidden { - continue; - } - - let doc_bounds = shape.extrect(tree, 1.0); - if !doc_bounds.intersects(viewport) { - continue; - } - - // Also require selrect to be visible; used for drag delta placement. - let selrect = shape.selrect(); - if !selrect.intersects(viewport) { - continue; - } - - candidates.push((shape.id, doc_bounds, selrect)); - } - - // Filter out any candidate that overlaps with any other candidate. - // Sort by left edge so the inner loop can break early once no further - // x-overlap is possible, reducing comparisons from O(N²) to O(N log N) - // in typical layouts where shapes are spread out. - candidates.sort_unstable_by(|a, b| { - a.1.left - .partial_cmp(&b.1.left) - .unwrap_or(std::cmp::Ordering::Equal) - }); - let n = candidates.len(); - let mut is_overlapping = vec![false; n]; - for i in 0..n { - for j in (i + 1)..n { - if candidates[j].1.left >= candidates[i].1.right { - break; // sorted: no further x-overlap possible for i - } - if is_overlapping[i] && is_overlapping[j] { - continue; // both already excluded, skip check - } - if candidates[i].1.intersects(candidates[j].1) { - is_overlapping[i] = true; - is_overlapping[j] = true; - } - } - } - let non_overlapping: Vec<(Uuid, Rect, Rect)> = candidates - .iter() - .zip(is_overlapping.iter()) - .filter_map(|((id, bounds, selrect), ov)| { - if !ov { - Some((*id, *bounds, *selrect)) - } else { - None - } - }) - .collect(); - - let vb_left = render_state.viewbox.area.left; - let vb_top = render_state.viewbox.area.top; - let (bb_w, bb_h) = render_state.surfaces.surface_size(SurfaceId::Backbuffer); - 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 = render_state.surfaces.atlas.snapshot_for_drag_crop(); - - // Scratch surface reused across all shapes that need the tile/atlas - // fallback — avoids one WebGL texture allocation per shape. - // Created lazily on first use and grown if a later shape needs more space. - let mut scratch_surface: Option = None; - - for (id, doc_bounds, selrect) in non_overlapping { - let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; - let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; - let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; - let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; - if right <= left || bottom <= top { - continue; - } - let src_irect = skia::IRect::new(left, top, right, bottom); - - let src_doc_bounds = Rect::new( - src_irect.left as f32 / scale + vb_left, - src_irect.top as f32 / scale + vb_top, - src_irect.right as f32 / scale + vb_left, - src_irect.bottom as f32 / scale + vb_top, - ); - - let full_w = src_irect.width(); - let full_h = src_irect.height(); - let (win_ox, win_oy, win_w, win_h) = drag_crop_snapshot_window_px( - max_snap_px, - full_w, - full_h, - viewport, - vb_left, - vb_top, - scale, - src_irect.left, - src_irect.top, - src_doc_bounds, - ); - let window_irect = skia::IRect::new( - src_irect.left + win_ox, - src_irect.top + win_oy, - src_irect.left + win_ox + win_w, - src_irect.top + win_oy + win_h, - ); - - let src_doc_window = Rect::new( - window_irect.left as f32 / scale + vb_left, - window_irect.top as f32 / scale + vb_top, - window_irect.right as f32 / scale + vb_left, - window_irect.bottom as f32 / scale + vb_top, - ); - - let in_backbuffer = window_irect.left >= 0 - && window_irect.top >= 0 - && window_irect.right <= bb_w - && window_irect.bottom <= bb_h; - - let backbuffer_snap = if in_backbuffer { - render_state - .surfaces - .snapshot_rect(SurfaceId::Backbuffer, window_irect) - } else { - None - }; - - let image = if let Some(img) = backbuffer_snap { - img - } else { - // Ensure the scratch surface is large enough for this window. - // Grow (reallocate) only when necessary so that the common case - // of similarly-sized shapes pays zero extra allocation cost. - let needs_alloc = scratch_surface - .as_ref() - .is_none_or(|s| s.width() < win_w || s.height() < win_h); - if needs_alloc { - scratch_surface = get_gpu_state() - .create_surface_with_isize( - "drag_crop_scratch".to_string(), - skia::ISize::new(win_w, win_h), - ) - .ok(); - } - let Some(scratch) = scratch_surface.as_mut() else { - continue; - }; - let Some(img) = render_state - .surfaces - .try_snapshot_doc_rect_from_tiles_and_atlas( - scratch, - atlas_snap.as_ref(), - src_doc_window, - window_irect, - win_w, - win_h, - vb_left, - vb_top, - scale, - ) - else { - continue; - }; - img - }; - - render_state.backbuffer_crop_cache.insert( - id, - InteractiveDragCrop { - src_doc_bounds: src_doc_window, - src_selrect: selrect, - capture_vb_left: vb_left, - capture_vb_top: vb_top, - capture_src_left: window_irect.left, - capture_src_top: window_irect.top, - image, - }, - ); - } -} diff --git a/render-wasm/src/render/drop_shadow.rs b/render-wasm/src/render/drop_shadow.rs index fba180d15e..ff335a8739 100644 --- a/render-wasm/src/render/drop_shadow.rs +++ b/render-wasm/src/render/drop_shadow.rs @@ -335,8 +335,7 @@ pub fn render_element_drop_shadows_and_composite( } if let Some(clips) = clip_bounds.as_ref() { - let antialias = !render_state.options.is_fast_mode() - && element.should_use_antialias(scale, render_state.options.antialias_threshold); + let antialias = element.should_use_antialias(scale, render_state.options.antialias_threshold); render_state.surfaces.canvas(target_surface).save(); render_state.clip_target_surface_to_stack(clips, target_surface, scale, antialias); render_state diff --git a/render-wasm/src/render/enter_exit.rs b/render-wasm/src/render/enter_exit.rs index f99bde5d40..1f6c484fe4 100644 --- a/render-wasm/src/render/enter_exit.rs +++ b/render-wasm/src/render/enter_exit.rs @@ -27,8 +27,7 @@ pub fn render_shape_enter( render_state.surfaces.canvas(target_surface).save(); if let Some(clips) = clip_bounds { let scale = render_state.get_scale(); - let antialias = !render_state.options.is_fast_mode() - && element + let antialias = element .should_use_antialias(scale, render_state.options.antialias_threshold); render_state.clip_target_surface_to_stack( clips, @@ -40,15 +39,13 @@ pub fn render_shape_enter( } let mut paint = skia::Paint::default(); - if !render_state.options.is_fast_mode() { - if let Some(blur) = element.masked_group_layer_blur() { - let scale = render_state.get_scale(); - let sigma = radius_to_sigma(blur.value * scale); - if let Some(filter) = - skia::image_filters::blur((sigma, sigma), None, None, None) - { - paint.set_image_filter(filter); - } + if let Some(blur) = element.masked_group_layer_blur() { + let scale = render_state.get_scale(); + let sigma = radius_to_sigma(blur.value * scale); + if let Some(filter) = + skia::image_filters::blur((sigma, sigma), None, None, None) + { + paint.set_image_filter(filter); } } @@ -81,13 +78,11 @@ pub fn render_shape_enter( paint.set_blend_mode(element.blend_mode().into()); paint.set_alpha_f(element.opacity()); - if !render_state.options.is_fast_mode() { - if let Some(frame_blur) = layer_blur::frame_clip_layer_blur(element) { - let scale = render_state.get_scale(); - let sigma = radius_to_sigma(frame_blur.value * scale); - if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { - paint.set_image_filter(filter); - } + if let Some(frame_blur) = layer_blur::frame_clip_layer_blur(element) { + let scale = render_state.get_scale(); + let sigma = radius_to_sigma(frame_blur.value * scale); + if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { + paint.set_image_filter(filter); } } diff --git a/render-wasm/src/render/export.rs b/render-wasm/src/render/export.rs index 8f84e1e0ee..f487cf7894 100644 --- a/render-wasm/src/render/export.rs +++ b/render-wasm/src/render/export.rs @@ -19,7 +19,7 @@ pub fn render_shape_pixels( let saved_export_context = render_state.export_context; let saved_render_area = render_state.render_area; let saved_render_area_with_margins = render_state.render_area_with_margins; - let saved_current_tile = render_state.current_tile; + let saved_current_tile = render_state.tile.current_tile; let saved_pending_nodes = std::mem::take(&mut render_state.pending_nodes); let saved_nested_fills = std::mem::take(&mut render_state.nested_fills); let saved_nested_blurs = std::mem::take(&mut render_state.nested_blurs); @@ -56,7 +56,7 @@ pub fn render_shape_pixels( mask: false, flattened: false, }); - render_state.render_shape_tree_partial_uncached(tree, timestamp, false, true)?; + render_state.render_shape_tree_tile(tree, timestamp, false, true)?; } render_state.export_context = None; @@ -77,7 +77,7 @@ pub fn render_shape_pixels( render_state.export_context = saved_export_context; render_state.render_area = saved_render_area; render_state.render_area_with_margins = saved_render_area_with_margins; - render_state.current_tile = saved_current_tile; + render_state.tile.current_tile = saved_current_tile; render_state.pending_nodes = saved_pending_nodes; render_state.nested_fills = saved_nested_fills; render_state.nested_blurs = saved_nested_blurs; @@ -86,7 +86,7 @@ pub fn render_shape_pixels( render_state.preview_mode = saved_preview_mode; let workspace_scale = render_state.get_scale(); - if let Some(tile) = render_state.current_tile { + if let Some(tile) = render_state.tile.current_tile { render_state.update_render_context(tile); } else if !render_state.render_area.is_empty() { render_state diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 7a6a3a3d39..f13747a6d3 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -1,6 +1,5 @@ // Render options flags const DEBUG_VISIBLE: u32 = 0x01; -const PROFILE_REBUILD_TILES: u32 = 0x02; const TEXT_EDITOR_V3: u32 = 0x04; const SHOW_WASM_INFO: u32 = 0x08; @@ -10,14 +9,13 @@ const SHOW_WASM_INFO: u32 = 0x08; const VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 1; const MIN_DPR_VIEWPORT_INTEREST_AREA_THRESHOLD: i32 = 2; const MAX_BLOCKING_TIME_MS: i32 = 32; -const NODE_BATCH_THRESHOLD: i32 = 3; +const NODE_BATCH_THRESHOLD: i32 = 100; const BLUR_DOWNSCALE_THRESHOLD: f32 = 8.0; const ANTIALIAS_THRESHOLD: f32 = 7.0; #[derive(Debug, Copy, Clone, PartialEq)] pub struct RenderOptions { pub flags: u32, pub dpr: f32, - fast_mode: bool, /// Active while the user is interacting with a shape (drag, resize, /// rotate). Implies `fast_mode` semantics for expensive effects but /// keeps per-frame flushing enabled (unlike pan/zoom, where @@ -30,7 +28,6 @@ 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 { @@ -38,7 +35,6 @@ impl Default for RenderOptions { Self { flags: 0, dpr: 1.0, - fast_mode: false, interactive_transform: false, antialias_threshold: ANTIALIAS_THRESHOLD, viewport_interest_area_threshold: VIEWPORT_INTEREST_AREA_THRESHOLD, @@ -46,7 +42,6 @@ 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, } } } @@ -56,23 +51,6 @@ impl RenderOptions { self.flags & DEBUG_VISIBLE == DEBUG_VISIBLE } - pub fn is_profile_rebuild_tiles(&self) -> bool { - self.flags & PROFILE_REBUILD_TILES == PROFILE_REBUILD_TILES - } - - /// Use fast mode to enable / disable expensive operations - pub fn is_fast_mode(&self) -> bool { - self.fast_mode - } - - pub fn set_fast_mode(&mut self, enabled: bool) { - 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 a89c934886..afefeaea87 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -5,15 +5,12 @@ use crate::{get_gpu_state, performance}; use skia_safe::{self as skia, IRect, Paint, RRect, Rect}; -use super::{gpu_state::GpuState, tiles, tiles::Tile, tiles::TileRect, tiles::TileViewbox}; +use super::{tiles, tiles::Tile, 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; @@ -25,28 +22,6 @@ const TILE_DRAWABLE_RECT: IRect = IRect { right: TILE_MARGIN_SIZE + TILE_SIZE, bottom: TILE_MARGIN_SIZE + TILE_SIZE, }; -const DOC_ATLAS_MAX_DIM: i32 = 4096; - -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 { - Yes, - No, -} #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] @@ -54,7 +29,6 @@ pub enum DrawOnCache { pub enum SurfaceId { Target = 0b000_0000_0001, Filter = 0b000_0000_0010, - Cache = 0b000_0000_0100, Current = 0b000_0000_1000, Fills = 0b000_0001_0000, Strokes = 0b000_0010_0000, @@ -64,346 +38,14 @@ pub enum SurfaceId { Export = 0b010_0000_0000, UI = 0b100_0000_0000, 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()); - } - - // Geometric over-allocation: pad by 25% of extent to reduce realloc frequency. - let doc_extent_w = new_right - new_left; - let doc_extent_h = new_bottom - new_top; - let pad = (doc_extent_w.min(doc_extent_h) * 0.25_f32).max(TILE_SIZE as f32); - 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() - .clamp(TILE_SIZE, DOC_ATLAS_MAX_DIM) 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 { // is the final destination surface, the one that it is represented in the canvas element. target: skia::Surface, filter: skia::Surface, - cache: skia::Surface, // keeps the current render current: skia::Surface, // keeps the current shape's fills @@ -427,9 +69,7 @@ pub struct Surfaces { // Atlas used to keep tiles. tile_atlas: skia::Surface, tile_atlas_image: Option, - tiles: TileTextureCache, - pub atlas: DocAtlas, sampling_options: skia::SamplingOptions, atlas_sampling_options: skia::SamplingOptions, pub margins: skia::ISize, @@ -456,7 +96,6 @@ impl Surfaces { let target = gpu_state.create_target_surface(width, height)?; let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; - 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)?; @@ -487,11 +126,9 @@ impl Surfaces { // 512, why not? let tiles = TileTextureCache::new(tile_atlas.width(), 512); - let atlas = DocAtlas::try_new()?; Ok(Self { target, filter, - cache, current, drop_shadows, inner_shadows, @@ -505,7 +142,6 @@ impl Surfaces { tile_atlas, tile_atlas_image: None, tiles, - atlas, sampling_options, atlas_sampling_options: skia::SamplingOptions::new( skia::FilterMode::Nearest, @@ -522,15 +158,10 @@ impl Surfaces { self.dpr = dpr; } - pub fn clear_tiles(&mut self) { - self.tiles.clear(); - } - pub fn draw_tile_atlas_to_backbuffer( &mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox, - background: skia::Color, ) { self.tiles.update(viewbox, tile_viewbox); if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() { @@ -538,10 +169,9 @@ impl Surfaces { self.tiles.snapshot(); } let Some(atlas_image) = self.tile_atlas_image.as_ref() else { - return; + panic!("Cannot draw tile atlas to backbuffer"); }; let canvas = self.backbuffer.canvas(); - canvas.clear(background); canvas.draw_atlas( atlas_image, &self.tiles.transforms, @@ -554,44 +184,6 @@ impl Surfaces { ); } - /// 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, background: skia::Color) { - if self.atlas.is_empty() { - return; - } - - let canvas = self.backbuffer.canvas(); - canvas.save(); - canvas.reset_matrix(); - let size = canvas.base_layer_size(); - canvas.clip_rect( - skia::Rect::from_xywh(0.0, 0.0, size.width as f32, size.height as f32), - None, - true, - ); - canvas.clear(background); - - 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, - )); - canvas.scale((s / scale, s / scale)); - - self.atlas.surface.draw( - canvas, - (0.0, 0.0), - self.sampling_options, - Some(&skia::Paint::default()), - ); - - canvas.restore(); - } - pub fn margins(&self) -> skia::ISize { self.margins } @@ -625,7 +217,7 @@ impl Surfaces { pub fn base64_snapshot_rect( &mut self, id: SurfaceId, - irect: skia::IRect, + irect: IRect, ) -> Result> { let surface = self.get_mut(id); if let Some(image) = surface.image_snapshot_with_bounds(irect) { @@ -641,7 +233,7 @@ impl Surfaces { } } - pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option { + pub fn snapshot_rect(&mut self, id: SurfaceId, irect: IRect) -> Option { let surface = self.get_mut(id); surface.image_snapshot_with_bounds(irect) } @@ -718,22 +310,6 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the backbuffer canvas. - /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_backbuffer(&mut self) { - let sampling_options = self.sampling_options; - self.cache.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - - pub fn cache_dimensions(&self) -> skia::ISize { - skia::ISize::new(self.cache.width(), self.cache.height()) - } - pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) { performance::begin_measure!("apply_mut::flags"); if ids & SurfaceId::Target as u32 != 0 { @@ -745,9 +321,6 @@ impl Surfaces { if ids & SurfaceId::Current as u32 != 0 { f(self.get_mut(SurfaceId::Current)); } - if ids & SurfaceId::Cache as u32 != 0 { - f(self.get_mut(SurfaceId::Cache)); - } if ids & SurfaceId::Backbuffer as u32 != 0 { f(self.get_mut(SurfaceId::Backbuffer)); } @@ -781,7 +354,7 @@ impl Surfaces { pub fn get_render_context_translation( &mut self, - render_area: skia::Rect, + render_area: Rect, scale: f32, ) -> (f32, f32) { ( @@ -790,7 +363,7 @@ impl Surfaces { ) } - pub fn update_render_context(&mut self, render_area: skia::Rect, scale: f32) { + pub fn update_render_context(&mut self, render_area: Rect, scale: f32) { let translation = self.get_render_context_translation(render_area, scale); // When context changes (zoom/pan/tile), clear all render surfaces first @@ -821,7 +394,6 @@ impl Surfaces { match id { SurfaceId::Target => &mut self.target, SurfaceId::Filter => &mut self.filter, - SurfaceId::Cache => &mut self.cache, SurfaceId::Backbuffer => &mut self.backbuffer, SurfaceId::Current => &mut self.current, SurfaceId::DropShadows => &mut self.drop_shadows, @@ -832,7 +404,6 @@ impl Surfaces { SurfaceId::Debug => &mut self.debug, SurfaceId::UI => &mut self.ui, SurfaceId::Export => &mut self.export, - SurfaceId::Atlas => &mut self.atlas.surface, SurfaceId::TileAtlas => &mut self.tile_atlas, } } @@ -842,7 +413,6 @@ impl Surfaces { match id { SurfaceId::Target => &self.target, SurfaceId::Filter => &self.filter, - SurfaceId::Cache => &self.cache, SurfaceId::Backbuffer => &self.backbuffer, SurfaceId::Current => &self.current, SurfaceId::DropShadows => &self.drop_shadows, @@ -853,7 +423,6 @@ impl Surfaces { SurfaceId::Debug => &self.debug, SurfaceId::UI => &self.ui, SurfaceId::Export => &self.export, - SurfaceId::Atlas => &self.atlas.surface, SurfaceId::TileAtlas => &self.tile_atlas, } } @@ -863,38 +432,13 @@ impl Surfaces { (s.width(), s.height()) } - /// Copy the current `Backbuffer` contents into `Target`. - /// This is a GPU→GPU copy via Skia (no ReadPixels). - /// - /// `Target` is cleared to `background` first so UI overlay pixels (guides, - /// grid) from the previous frame are fully erased. Without this, `SrcOver` - /// compositing would keep stale overlay pixels wherever the backbuffer is - /// transparent. - pub fn copy_backbuffer_to_target(&mut self, background: skia::Color) { + pub fn copy_backbuffer_to_target(&mut self) { let sampling_options = self.sampling_options; - let canvas = self.target.canvas(); - canvas.clear(background); - self.backbuffer.draw( - canvas, - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - - /// Replace `Target` pixels with `Backbuffer` (Src blend). - /// - /// Used for viewer masked passes: transparent backbuffer regions must not - /// preserve prior `Target` content from an earlier pass. - pub fn copy_backbuffer_to_target_replace(&mut self) { - let sampling_options = self.sampling_options; - let mut paint = skia::Paint::default(); - paint.set_blend_mode(skia::BlendMode::Src); self.backbuffer.draw( self.target.canvas(), (0.0, 0.0), sampling_options, - Some(&paint), + None, ); } @@ -906,17 +450,6 @@ impl Surfaces { self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); } - /// Seed `Backbuffer` from `Target` (last presented frame). - pub fn seed_backbuffer_from_target(&mut self) { - let sampling_options = self.sampling_options; - self.target.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - fn reset_from_target(&mut self, target: skia::Surface) -> Result<()> { let dim = (target.width(), target.height()); self.target = target; @@ -940,41 +473,6 @@ impl Surfaces { Ok(()) } - pub fn resize_cache( - &mut self, - cache_dims: skia::ISize, - interest_area_threshold: i32, - ) -> Result<()> { - self.cache = self - .target - .new_surface_with_dimensions(cache_dims) - .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; - self.cache.canvas().reset_matrix(); - self.cache.canvas().translate(( - (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, @@ -1073,7 +571,7 @@ impl Surfaces { self.backbuffer.canvas().clear(color); } - pub fn clear_backbuffer_rect(&mut self, rect: skia::Rect, color: skia::Color) { + pub fn clear_backbuffer_rect(&mut self, rect: Rect, color: skia::Color) { let mut paint = Paint::default(); paint.set_color(color); self.backbuffer.canvas().draw_rect(rect, &paint); @@ -1150,51 +648,20 @@ impl Surfaces { self.clear_all_dirty(); } - /// Clears the whole cache surface without disturbing its configured transform. - pub fn clear_cache(&mut self, color: skia::Color) { - let canvas = self.cache.canvas(); - canvas.save(); - canvas.reset_matrix(); - canvas.clear(color); - canvas.restore(); - } - pub fn draw_current_tile_into_tile_atlas( &mut self, - tile_viewbox: &TileViewbox, tile: &Tile, - tile_rect: &skia::Rect, - skip_cache_surface: bool, - tile_doc_rect: skia::Rect, ) { - let gpu_state = get_gpu_state(); 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 - self.cache.canvas().draw_image_rect( - &tile_image, - None, - tile_rect, - &skia::Paint::default(), - ); - } - - // 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 - .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 - if let Some(tile_ref) = self.tiles.add(tile_viewbox, tile) { + if let Some(tile_ref) = self.tiles.add(tile) { self.tile_atlas.canvas().draw_image_rect( &tile_image, None, - tile_ref.rect, + tile_ref, &skia::Paint::default(), ); } @@ -1205,181 +672,11 @@ impl Surfaces { self.tiles.has(tile) } - /// 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. - /// - /// `scratch` must be at least `out_w × out_h` pixels — the caller is responsible - /// for allocating (and **reusing across shapes**) a surface large enough to hold - /// 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. - /// - /// For each tile cell intersecting `src_doc_bounds`: draws from - /// [`TileTextureCache`] when present; otherwise samples the atlas. - #[allow(clippy::too_many_arguments)] - pub fn try_snapshot_doc_rect_from_tiles_and_atlas( - &mut self, - scratch: &mut skia::Surface, - atlas_snap: Option<&(skia::Image, f32, skia::Point)>, - src_doc_bounds: skia::Rect, - src_irect: IRect, - out_w: i32, - out_h: i32, - vb_left: f32, - vb_top: f32, - scale: f32, - ) -> Option { - if out_w <= 0 || out_h <= 0 || src_doc_bounds.is_empty() { - return None; - } - - let canvas = scratch.canvas(); - canvas.clear(skia::Color::TRANSPARENT); - - let tile_size = tiles::get_tile_size(scale); - let tr = tiles::get_tiles_for_rect(src_doc_bounds, tile_size); - let ix0 = src_irect.left as f32; - let iy0 = src_irect.top as f32; - let paint = skia::Paint::default(); - - for ty in tr.y1()..=tr.y2() { - for tx in tr.x1()..=tr.x2() { - let tile = Tile(tx, ty); - let tile_doc = tiles::get_tile_rect(tile, scale); - let mut clip_doc = tile_doc; - if !clip_doc.intersect(src_doc_bounds) || clip_doc.is_empty() { - continue; - } - - let dst = skia::Rect::from_ltrb( - (clip_doc.left - vb_left) * scale - ix0, - (clip_doc.top - vb_top) * scale - iy0, - (clip_doc.right - vb_left) * scale - ix0, - (clip_doc.bottom - vb_top) * scale - iy0, - ); - - 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); - let td_h = tile_doc.height().max(1e-6); - - let src = skia::Rect::from_ltrb( - ((clip_doc.left - tile_doc.left) / td_w) * iw, - ((clip_doc.top - tile_doc.top) / td_h) * ih, - ((clip_doc.right - tile_doc.left) / td_w) * iw, - ((clip_doc.bottom - tile_doc.top) / td_h) * ih, - ); - - canvas.draw_image_rect( - tile_image, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &paint, - ); - } else { - let snap = atlas_snap?; - 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 { - continue; - } - let src = skia::Rect::from_xywh(sx, sy, sw, sh); - canvas.draw_image_rect( - atlas, - Some((&src, skia::canvas::SrcRectConstraint::Fast)), - dst, - &paint, - ); - } - } - } - - scratch.image_snapshot_with_bounds(IRect::new(0, 0, out_w, out_h)) - } - pub fn remove_cached_tile_surface(&mut self, tile: Tile) { - let gpu_state = get_gpu_state(); // Mark tile as invalid // Old content stays visible until new tile overwrites it atomically, // preventing flickering during tile re-renders. self.tiles.remove(tile); - // Also clear the corresponding region in the persistent atlas to avoid - // leaving stale pixels when shapes move/delete. - let _ = self.atlas.clear_tile_in_atlas(gpu_state, tile); - } - - /// 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_into_backbuffer( - &mut self, - tile_rect: &skia::Rect, - _color: skia::Color, - draw_on_cache: DrawOnCache, - ) { - let sampling_options = self.sampling_options; - let src_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 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); - - // Draw current surface directly to target (no snapshot) - self.current.draw( - backbuffer_canvas, - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - - // Also draw to cache for render_from_cache - if draw_on_cache == DrawOnCache::Yes { - self.current.draw( - self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - } - } - - /// Full cache reset: clears both the tile texture cache and the cache canvas. - /// Used by `rebuild_tiles` (full rebuild). For shallow rebuilds that preserve - /// 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.cache.canvas().clear(color); } /// Invalidate the tile texture cache without clearing the cache canvas. @@ -1387,16 +684,12 @@ impl Surfaces { /// so that `render_from_cache` can still show a scaled preview of the old /// content while new tiles are being rendered. pub fn invalidate_tile_cache(&mut self) { + println!("invalidate_tile_cache"); self.tiles.clear(); - self.atlas.tile_doc_rects.clear(); self.tile_atlas_image = None; } - pub fn gc(&mut self) { - self.tiles.gc(); - } - - pub fn resize_export_surface(&mut self, scale: f32, rect: skia::Rect) { + pub fn resize_export_surface(&mut self, scale: f32, rect: Rect) { let target_w = (scale * rect.width()).ceil() as i32; let target_h = (scale * rect.height()).ceil() as i32; @@ -1448,22 +741,7 @@ 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, } @@ -1480,36 +758,22 @@ impl TileAtlasTextureProvider { 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 available(&self) -> usize { + self.rects.len() } - 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; + pub fn allocate(&mut self) -> Option { + self.rects.pop() + } + + pub fn deallocate(&mut self, rect: Rect) -> bool { + println!("Deallocating {:?}", rect); + debug_assert!(!self.rects.contains(&rect), "Deallocating an already deallocated rect"); + self.rects.push(rect); true } } @@ -1519,8 +783,8 @@ pub struct TileTextureCache { is_updated: bool, provider: TileAtlasTextureProvider, transforms: Vec, - textures: Vec, - grid: HashMap, + textures: Vec, + grid: HashMap, removed: HashSet, } @@ -1539,8 +803,7 @@ impl TileTextureCache { fn gc(&mut self) { // Make a real remove - let removed = std::mem::take(&mut self.removed); - for tile in removed { + for tile in self.removed.drain() { if let Some(tile_ref) = self.grid.remove(&tile) { self.provider.deallocate(tile_ref); } @@ -1555,27 +818,6 @@ impl TileTextureCache { self.is_updated = false; } - fn gc_non_visible(&mut self, tile_viewbox: &TileViewbox) { - let marked: Vec<_> = self - .grid - .iter_mut() - .filter_map(|(tile, _)| { - if !tile_viewbox.is_visible(tile) { - Some(*tile) - } else { - None - } - }) - .take(TEXTURES_BATCH_DELETE) - .collect(); - - for tile in marked.iter() { - if let Some(tile_ref) = self.grid.remove(tile) { - self.provider.deallocate(tile_ref); - } - } - } - pub fn update(&mut self, viewbox: &Viewbox, tile_viewbox: &TileViewbox) { if self.transforms.len() != tile_viewbox.visible_rect.len() as usize { self.transforms.resize( @@ -1587,7 +829,7 @@ impl TileTextureCache { 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(), + Rect::new_empty(), ); } @@ -1613,10 +855,10 @@ impl TileTextureCache { 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, + tile_ref.left, + tile_ref.top, + tile_ref.right, + tile_ref.bottom, ); index += 1; @@ -1628,22 +870,14 @@ impl TileTextureCache { self.grid.contains_key(&tile) && !self.removed.contains(&tile) } - pub fn add(&mut self, tile_viewbox: &TileViewbox, tile: &Tile) -> Option { - if self.grid.len() > TEXTURES_CACHE_CAPACITY { - // First we try to remove the obsolete tiles. - self.gc(); - } + pub fn add(&mut self, tile: &Tile) -> Option { + 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.gc_non_visible(tile_viewbox); - } + let Some(tile_ref) = self.provider.allocate() else { + panic!("Cannot allocate more rects"); + }; - let tile_ref = self.provider.allocate()?; - - self.grid.insert(*tile, tile_ref.clone()); + self.grid.insert(*tile, tile_ref); if self.removed.contains(tile) { self.removed.remove(tile); @@ -1653,25 +887,16 @@ impl TileTextureCache { Some(tile_ref) } - pub fn get(&mut self, tile: Tile) -> Option<&TileAtlasTextureRef> { - if self.removed.contains(&tile) { - return None; - } - 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(); - } - } + println!("remove {:?}", tile); self.is_updated = true; self.removed.insert(tile); } pub fn clear(&mut self) { + println!("clear"); for k in self.grid.keys() { + println!("{:?}", k); self.removed.insert(*k); } self.is_updated = true; diff --git a/render-wasm/src/render/view_mode.rs b/render-wasm/src/render/view_mode.rs index 7f4af88314..8ecf19b8d1 100644 --- a/render-wasm/src/render/view_mode.rs +++ b/render-wasm/src/render/view_mode.rs @@ -22,7 +22,7 @@ pub fn precompute_viewer_visible_set(render_state: &mut RenderState, tree: Shape let Some(ref include) = render_state.include_filter else { return; }; - let mut visible = include.clone(); + let mut visible: HashSet = include.clone(); for id in include.iter() { let mut current_id = id; while let Some(raw) = tree.get_raw(current_id) { diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 95c704ceff..7fe2cdd515 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1382,108 +1382,6 @@ impl Shape { } } - /// Same `concat` applied around [`center`](Self::center) as in `render_shape` (non-text branch). - fn shape_document_transform(&self) -> Matrix { - let c = self.center(); - let mut m = self.transform; - m.post_translate(c); - m.pre_translate(-c); - m - } - - /// Fill silhouette only, document space (matches fill rendering). - fn drag_crop_fill_clip_path_skia(&self) -> Option { - match &self.shape_type { - Type::Rect(r) => { - let p = Path::new(shape_to_path::rect_segments(self, r.corners)); - Some(p.to_skia_path(self.svg_attrs.as_ref())) - } - Type::Circle => { - let p = Path::new(shape_to_path::circle_segments(self)); - Some(p.to_skia_path(self.svg_attrs.as_ref())) - } - Type::Path(_) | Type::Bool(_) => { - let sk = self.get_skia_path()?; - Some(sk.make_transform(&self.shape_document_transform())) - } - _ => None, - } - } - - /// Whether this shape may use the backbuffer crop fast path during interactive drag. - /// - /// Conservative: only effects and fills that match what we snapshot and clip in - /// [`drag_crop_clip_path`](Self::drag_crop_clip_path). Text is never safe (glyph layout, - /// no `drag_crop_clip_path`). - pub fn is_safe_for_drag_crop_cache(&self, shapes_pool: ShapesPoolRef) -> bool { - if matches!(self.shape_type, Type::Text(_)) { - return false; - } - - // If a frame shows overflow (clip_content=false) and its visible content exceeds the - // frame bounds, a cached crop anchored to the frame can easily become incorrect while - // moving (children can extend beyond selrect). Be conservative and render live. - if matches!(self.shape_type, Type::Frame(_)) && !self.clip_content { - let extrect = self.extrect(shapes_pool, 1.0); - let sr = self.selrect; - let exceeds = extrect.left < sr.left - || extrect.top < sr.top - || extrect.right > sr.right - || extrect.bottom > sr.bottom; - if exceeds { - return false; - } - } - - self.blur.is_none() - && self.background_blur.is_none() - && self.shadows.is_empty() - && (self.opacity - 1.0).abs() <= 1e-4 - && self.blend_mode().0 == skia::BlendMode::SrcOver - } - - /// Fill + visible strokes in **document space** for clipping interactive drag textures. - /// - /// The backbuffer crop uses an axis-aligned `extrect`; we clip the blit so backdrop pixels - /// outside the real silhouette (fill and stroke regions) are not smeared. Strokes use - /// [`stroke_to_path`](stroke_to_path) like the main renderer, then union with the fill path. - pub fn drag_crop_clip_path(&self) -> Option { - let mut acc = self.drag_crop_fill_clip_path_skia()?; - if !self.has_visible_strokes() { - return Some(acc); - } - - let shape_path = match &self.shape_type { - Type::Rect(r) => Path::new(shape_to_path::rect_segments(self, r.corners)), - Type::Circle => Path::new(shape_to_path::circle_segments(self)), - Type::Path(_) | Type::Bool(_) => self.shape_type.path()?.clone(), - _ => return Some(acc), - }; - - let path_transform = self.to_path_transform(); - let apply_doc_transform = path_transform.is_some(); - - for stroke in self.visible_strokes() { - let Some(stroke_region) = stroke_to_path( - stroke, - &shape_path, - path_transform.as_ref(), - &self.selrect, - self.svg_attrs.as_ref(), - true, - ) else { - continue; - }; - let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref()); - if apply_doc_transform { - sk = sk.make_transform(&self.shape_document_transform()); - } - acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc); - } - - Some(acc) - } - fn transform_selrect(&mut self, transform: &Matrix) { if math::is_move_only_matrix(transform) { let tx = transform.translate_x(); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 2ae6ba4067..92fb8170a3 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -65,26 +65,27 @@ impl State { Ok(()) } - pub fn render_from_cache(&mut self) { - get_render_state().render_from_cache(&self.shapes); - } + // pub fn render_from_cache(&mut self) { + // get_render_state().render_from_cache(&self.shapes); + // } pub fn render_ui_only(&mut self) { get_render_state().render_ui_only(&self.shapes); } pub fn render_blurred_snapshot(&mut self, blur_radius: f32) { - get_render_state().render_blurred_snapshot(&self.shapes, blur_radius); + get_render_state().render_blurred_snapshot(blur_radius); } pub fn render_sync(&mut self, timestamp: i32) -> Result { - get_render_state().start_render_loop(None, &self.shapes, timestamp, true) + get_render_state().start_render_loop(timestamp, true) } pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result { let render_state = get_render_state(); render_state.prepare_sync_shape_render(); - render_state.start_render_loop(Some(id), &self.shapes, timestamp, true) + render_state.base_object = Some(*id); + render_state.start_render_loop(timestamp, true) } pub fn render_shape_pixels( @@ -100,24 +101,26 @@ impl State { crate::render::pdf::render_to_pdf(get_render_state(), id, &self.shapes, scale) } - 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 - // index so shapes are mapped to the correct tiles. We use - // rebuild_tile_index (NOT rebuild_tiles_shallow) to preserve the tile - // texture cache — otherwise cached tiles with shadows/blur would be - // cleared and re-rendered in fast mode without effects. - if render_state.zoom_changed() { - render_state.rebuild_tile_index(&self.shapes); - } - render_state.start_render_loop(None, &self.shapes, timestamp, false) - } + // pub fn start_render_loop(&mut self, timestamp: i32) -> Result { + // let render_state = get_render_state(); - pub fn continue_render_loop(&mut self, timestamp: i32) -> Result { - let allow_stop = true; - get_render_state().continue_render_loop(None, &self.shapes, timestamp, allow_stop) - } + // render_state.tile_viewbox.update(&render_state.viewbox); + // render_state.rebuild_tile_index(&self.shapes); + // if render_state.is_zoom_changed() { + // render_state.surfaces.invalidate_tile_cache(); + // } + // render_state.start_render_loop( + // None, + // &self.shapes, + // timestamp, + // false + // ) + // } + + // pub fn continue_render_loop(&mut self, timestamp: i32) -> Result { + // let allow_stop = true; + // get_render_state().continue_render_loop(None, &self.shapes, timestamp, allow_stop) + // } pub fn clear_focus_mode(&mut self) { get_render_state().clear_focus_mode(); @@ -169,6 +172,7 @@ impl State { // Instead, remove the shape from *all* tiles where it was indexed, and // drop cached tiles for those entries. let indexed_tiles: Vec = render_state + .tile .tiles .get_tiles_of(shape.id) .map(|t| t.iter().copied().collect()) @@ -176,7 +180,7 @@ impl State { for tile in indexed_tiles { render_state.remove_cached_tile(tile); - render_state.tiles.remove_shape_at(tile, shape.id); + render_state.tile.tiles.remove_shape_at(tile, shape.id); } if let Some(shape_to_delete) = self.shapes.get(&id) { @@ -242,7 +246,7 @@ impl State { } pub fn rebuild_tiles_shallow(&mut self) { - get_render_state().rebuild_tiles_shallow(&self.shapes); + get_render_state().rebuild_tiles_shallow(); } pub fn rebuild_tiles(&mut self) { @@ -254,15 +258,15 @@ impl State { } pub fn rebuild_touched_tiles(&mut self) { - get_render_state().rebuild_touched_tiles(&self.shapes); + get_render_state().rebuild_touched_tiles(); } pub fn render_preview(&mut self, timestamp: i32) { - let _ = get_render_state().render_preview(&self.shapes, timestamp); + let _ = get_render_state().render_preview(timestamp); } pub fn rebuild_modifier_tiles(&mut self, ids: &[Uuid]) -> Result<()> { - get_render_state().rebuild_modifier_tiles(&mut self.shapes, ids) + get_render_state().rebuild_modifier_tiles(ids) } pub fn font_collection(&self) -> &FontCollection { diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 0d32b1ae1c..1319e8ab5a 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -30,16 +30,6 @@ impl Tile { 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)] diff --git a/render-wasm/src/view.rs b/render-wasm/src/view.rs index efa5394012..dbd9ba9d84 100644 --- a/render-wasm/src/view.rs +++ b/render-wasm/src/view.rs @@ -1,23 +1,34 @@ use crate::math::{Matrix, Point, Rect, Size}; use std::ops::Mul; +#[repr(u32)] +pub enum ViewboxUpdated { + None = 0b0000, + Position = 0b0001, + Zoom = 0b0010, + Size = 0b0100, + All = 0b0111, +} + #[derive(Debug, Copy, Clone)] pub(crate) struct Viewbox { - pub pan: Point, + pub position: Point, pub size: Size, pub zoom: f32, pub dpr: f32, pub area: Rect, + pub updated: u32, } impl Default for Viewbox { fn default() -> Self { Self { - pan: Point::new(0.0, 0.0), + position: Point::new(0.0, 0.0), size: Size::new(0.0, 0.0), zoom: 1.0, dpr: 1.0, area: Rect::new_empty(), + updated: ViewboxUpdated::All as u32, } } } @@ -50,21 +61,42 @@ impl Viewbox { self.size.height } - pub fn set_all(&mut self, zoom: f32, pan_x: f32, pan_y: f32) { - self.pan.set(pan_x, pan_y); - self.zoom = zoom; - self.area.set_xywh( - -self.pan.x, - -self.pan.y, - self.size.width / self.zoom, - self.size.height / self.zoom, - ); + pub fn set_all(&mut self, zoom: f32, x: f32, y: f32) { + self.set_position(x, y); + self.set_zoom(zoom); + if self.updated != ViewboxUpdated::None as u32 { + self.area.set_xywh( + -self.position.x, + -self.position.y, + self.size.width / self.zoom, + self.size.height / self.zoom, + ); + } + } + + pub fn set_position(&mut self, x: f32, y: f32) { + if self.position.x != x { + self.position.x = x; + self.updated |= ViewboxUpdated::Position as u32; + } + if self.position.y != y { + self.position.y = y; + self.updated |= ViewboxUpdated::Position as u32; + } + } + + pub fn set_zoom(&mut self, zoom: f32) { + if self.zoom != zoom { + self.zoom = zoom; + self.updated = ViewboxUpdated::Zoom as u32; + } } pub fn set_wh(&mut self, width: f32, height: f32) { self.size.set(width, height); self.area .set_wh(self.size.width / self.zoom, self.size.height / self.zoom); + self.updated = ViewboxUpdated::Size as u32; } pub fn set_dpr(&mut self, dpr: f32) { @@ -80,7 +112,7 @@ impl Viewbox { } pub fn pan(&self) -> Point { - self.pan + self.position } pub fn zoom(&self) -> f32 { @@ -93,4 +125,24 @@ impl Viewbox { matrix.post_scale((self.zoom, self.zoom), None); matrix } + + pub fn is_updated(&self, flags: u32) -> bool { + self.updated & flags == flags + } + + pub fn is_zoom_changed(&self) -> bool { + self.is_updated(ViewboxUpdated::Zoom as u32) + } + + pub fn is_position_changed(&self) -> bool { + self.is_updated(ViewboxUpdated::Position as u32) + } + + pub fn is_size_changed(&self) -> bool { + self.is_updated(ViewboxUpdated::Size as u32) + } + + pub fn update_handled(&mut self) { + self.updated = ViewboxUpdated::None as u32; + } }