⚡ Reduce render-wasm GPU memory and move cost on large pages (#11980)

This commit is contained in:
Elena Torró 2026-10-02 10:24:05 +02:00 committed by GitHub
parent 84c794c5b8
commit 09ffcb1fbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 173 additions and 147 deletions

View File

@ -21,7 +21,6 @@
[app.common.types.component :as ctk] [app.common.types.component :as ctk]
[app.common.types.container :as ctn] [app.common.types.container :as ctn]
[app.common.types.modifiers :as ctm] [app.common.types.modifiers :as ctm]
[app.common.types.path :as path]
[app.common.types.shape-tree :as ctst] [app.common.types.shape-tree :as ctst]
[app.common.types.shape.attrs :refer [editable-attrs]] [app.common.types.shape.attrs :refer [editable-attrs]]
[app.common.types.shape.layout :as ctl] [app.common.types.shape.layout :as ctl]
@ -885,6 +884,18 @@
ids ids
(into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms)) (into (set (keys modif-tree)) xf:without-uuid-zero (keys transforms))
options
(cond-> options
translation?
(assoc :resize-ids
(into []
(remove (fn [id]
(let [parent-id (dm/get-in objects [id :parent-id])]
(and (contains? ids parent-id)
(= (get transforms id)
(get transforms parent-id))))))
ids)))
update-shape update-shape
(fn [shape] (fn [shape]
(let [shape-id (dm/get-prop shape :id) (let [shape-id (dm/get-prop shape :id)
@ -894,14 +905,6 @@
(gsh/apply-transform transform) (gsh/apply-transform transform)
(ctm/apply-structure-modifiers modifiers)))) (ctm/apply-structure-modifiers modifiers))))
bool-ids
(into #{}
(comp
(mapcat (partial cfh/get-parents-with-self objects))
(filter cfh/bool-shape?)
(map :id))
ids)
undo-id (js/Symbol)] undo-id (js/Symbol)]
(rx/concat (rx/concat
@ -912,16 +915,7 @@
(clear-local-transform) (clear-local-transform)
(ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms}) (ptk/event ::dwg/move-frame-guides {:ids ids :transforms transforms})
(ptk/event ::dwcm/move-frame-comment-threads transforms) (ptk/event ::dwcm/move-frame-comment-threads transforms)
(dwsh/update-shapes ids update-shape options) (dwsh/update-shapes ids update-shape options))
;; The update to the bool path needs to be in a different operation because it
;; needs to have the updated children info.
;; `update-layout? false`: recalculating a bool path can never change
;; `:hidden`, and the layout check would recompute the whole boolean
;; path in WASM once per bool shape just to find that out.
(dwsh/update-shapes bool-ids path/update-bool-shape (assoc options
:with-objects? true
:update-layout? false)))
(if undo-transation? (if undo-transation?
(rx/of (dwu/commit-undo-transaction undo-id)) (rx/of (dwu/commit-undo-transaction undo-id))

View File

@ -110,7 +110,7 @@
([ids update-fn ([ids update-fn
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr changed-item-index ignore-touched undo-group with-objects? changed-sub-attr changed-item-index
translation? skip-grid-reassignment? skip-component-sync?] translation? skip-grid-reassignment? skip-component-sync? resize-ids]
:or {reg-objects? false :or {reg-objects? false
save-undo? true save-undo? true
stack-undo? false stack-undo? false
@ -153,7 +153,7 @@
:ignore-touched ignore-touched :ignore-touched ignore-touched
:with-objects? with-objects? :with-objects? with-objects?
:skip-grid-reassignment? skip-grid-reassignment?}) :skip-grid-reassignment? skip-grid-reassignment?})
(cond-> reg-objects? (pcb/resize-parents ids)) (cond-> reg-objects? (pcb/resize-parents (or resize-ids ids)))
(pcb/set-translation? translation?) (pcb/set-translation? translation?)
(pcb/set-skip-component-sync? skip-component-sync?))))] (pcb/set-skip-component-sync? skip-component-sync?))))]
;; Check buffered text candidates when the buffer is committed. ;; Check buffered text candidates when the buffer is committed.
@ -191,7 +191,7 @@
{:as props {:as props
:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id :keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr changed-item-index translation? ignore-touched undo-group with-objects? changed-sub-attr changed-item-index translation?
skip-grid-reassignment? skip-component-sync?] skip-grid-reassignment? skip-component-sync? resize-ids]
:or {reg-objects? false :or {reg-objects? false
save-undo? true save-undo? true
stack-undo? false stack-undo? false
@ -250,7 +250,7 @@
(rx/empty)) (rx/empty))
(if (seq (:redo-changes changes)) (if (seq (:redo-changes changes))
(let [changes (cond-> changes reg-objects? (pcb/resize-parents ids))] (let [changes (cond-> changes reg-objects? (pcb/resize-parents (or resize-ids ids)))]
(rx/of (dch/commit-changes changes))) (rx/of (dch/commit-changes changes)))
(rx/empty)) (rx/empty))

View File

@ -0,0 +1,99 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns frontend-tests.logic.bool-move-test
"Moving shapes must only recalculate booleans whose children moved
relative to them, and leave every boolean equal to a fresh calculation."
(:require
[app.common.geom.point :as gpt]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.modifiers :as ctm]
[app.common.types.pages-list :as ctpl]
[app.common.types.path :as path]
[app.common.types.shape-tree :as ctst]
[app.main.data.workspace.modifiers :as dwm]
[app.render-wasm.api :as wasm.api]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.helpers.wasm :as thw]))
(def ^:private bool-calculations (atom 0))
(defn- counting-calc-bool-content
[shape objects]
(swap! bool-calculations inc)
(path/calc-bool-content shape objects))
(def ^:private original-calculate-bool wasm.api/calculate-bool)
(t/use-fixtures :each
{:before (fn []
(cthi/reset-idmap!)
(thw/setup-wasm-mocks!)
(reset! bool-calculations 0)
(set! wasm.api/calculate-bool counting-calc-bool-content)
(set! path/wasm:calc-bool-content counting-calc-bool-content))
:after (fn []
(set! wasm.api/calculate-bool original-calculate-bool)
(set! path/wasm:calc-bool-content nil)
(thw/teardown-wasm-mocks!))})
(defn- file-with-bool-in-board
[]
(let [file (-> (cthf/sample-file :file1)
(ctho/add-frame :board :x 0 :y 0 :width 400 :height 400)
(cths/add-sample-shape :bool {:type :bool
:bool-type :union
:parent-label :board})
(ctho/add-rect :rect1 :x 10 :y 10 :width 100 :height 100 :parent-label :bool)
(ctho/add-rect :rect2 :x 60 :y 60 :width 100 :height 100 :parent-label :bool))
page (cthf/current-page file)
bool (path/update-bool-shape (cths/get-shape file :bool) (:objects page))]
(update file :data ctpl/update-page (:id page) #(ctst/set-shape % bool))))
(defn- disable-pixel-grid
[]
(fn [state]
(update state :workspace-layout disj :snap-pixel-grid)))
(defn- run-move
[done labels delta expected-calculations & {:keys [pixel-grid?] :or {pixel-grid? true}}]
(let [file (file-with-bool-in-board)
store (ths/setup-store file)
ids (map #(:id (cths/get-shape file %)) labels)
modif-tree (dwm/create-modif-tree ids (ctm/move-modifiers delta))
events (cond->> [(dwm/apply-wasm-modifiers modif-tree)]
(not pixel-grid?) (cons (disable-pixel-grid)))]
(reset! bool-calculations 0)
(ths/run-store
store done events
(fn [new-state]
(let [calculations @bool-calculations
file' (ths/get-file-from-state new-state)
objects' (:objects (cthf/current-page file'))
after (cths/get-shape file' :bool)
fresh (path/update-bool-shape after objects')]
(t/is (= expected-calculations calculations))
(t/is (= (:selrect fresh) (:selrect after)))
(t/is (= (vec (:content fresh)) (vec (:content after)))))))))
(t/deftest moving-a-board-does-not-recalculate-the-booleans-inside
(t/async
done
(run-move done [:board :bool :rect1 :rect2] (gpt/point 10 20) 0)))
(t/deftest moving-a-boolean-child-recalculates-the-boolean
(t/async
done
(run-move done [:rect1] (gpt/point 10 20) 1)))
(t/deftest moving-a-board-without-pixel-grid-does-not-recalculate-its-booleans
(t/async
done
(run-move done [:board] (gpt/point 10.5 20.25) 0 :pixel-grid? false)))

View File

@ -39,6 +39,7 @@
[frontend-tests.errors-test] [frontend-tests.errors-test]
[frontend-tests.fonts-test] [frontend-tests.fonts-test]
[frontend-tests.helpers-shapes-test] [frontend-tests.helpers-shapes-test]
[frontend-tests.logic.bool-move-test]
[frontend-tests.logic.comp-remove-swap-slots-test] [frontend-tests.logic.comp-remove-swap-slots-test]
[frontend-tests.logic.components-and-tokens] [frontend-tests.logic.components-and-tokens]
[frontend-tests.logic.copy-paste-typography-test] [frontend-tests.logic.copy-paste-typography-test]
@ -174,6 +175,7 @@
'frontend-tests.errors-test 'frontend-tests.errors-test
'frontend-tests.fonts-test 'frontend-tests.fonts-test
'frontend-tests.helpers-shapes-test 'frontend-tests.helpers-shapes-test
'frontend-tests.logic.bool-move-test
'frontend-tests.logic.comp-remove-swap-slots-test 'frontend-tests.logic.comp-remove-swap-slots-test
'frontend-tests.logic.components-and-tokens 'frontend-tests.logic.components-and-tokens
'frontend-tests.logic.copy-paste-typography-test 'frontend-tests.logic.copy-paste-typography-test

View File

@ -1202,7 +1202,9 @@ impl RenderState {
// This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), // 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. // while still preventing stale pixels from surviving across full-quality renders.
if !self.cache_cleared_this_render { if !self.cache_cleared_this_render {
self.surfaces.clear_cache(self.background_color); if self.options.is_debug_visible() {
self.surfaces.clear_cache(self.background_color);
}
self.cache_cleared_this_render = true; self.cache_cleared_this_render = true;
} }
let tile_rect = self.get_current_aligned_tile_bounds()?; let tile_rect = self.get_current_aligned_tile_bounds()?;
@ -1220,7 +1222,7 @@ impl RenderState {
&self.tile_viewbox, &self.tile_viewbox,
&current_tile, &current_tile,
&tile_rect, &tile_rect,
false, !self.options.is_debug_visible(),
self.render_area, self.render_area,
self.get_scale(), self.get_scale(),
self.viewbox.area, self.viewbox.area,
@ -2333,10 +2335,7 @@ impl RenderState {
.is_none_or(|s| s.width() < win_w || s.height() < win_h); .is_none_or(|s| s.width() < win_w || s.height() < win_h);
if needs_alloc { if needs_alloc {
scratch_surface = get_gpu_state() scratch_surface = get_gpu_state()
.create_surface_with_isize( .create_surface_with_isize(skia::ISize::new(win_w, win_h))
"drag_crop_scratch".to_string(),
skia::ISize::new(win_w, win_h),
)
.ok(); .ok();
} }
let Some(scratch) = scratch_surface.as_mut() else { let Some(scratch) = scratch_surface.as_mut() else {
@ -2512,11 +2511,13 @@ impl RenderState {
s.canvas().scale((scale, scale)); s.canvas().scale((scale, scale));
}); });
self.surfaces.resize_cache_from_viewbox( if self.options.is_debug_visible() {
&self.viewbox, self.surfaces.resize_cache_from_viewbox(
&self.cached_viewbox, &self.viewbox,
self.options.dpr_viewport_interest_area_threshold, &self.cached_viewbox,
)?; self.options.dpr_viewport_interest_area_threshold,
)?;
}
// FIXME - review debug // FIXME - review debug
// debug::render_debug_tiles_for_viewbox(self); // debug::render_debug_tiles_for_viewbox(self);
@ -4319,11 +4320,16 @@ impl RenderState {
if !is_empty || self.current_tile_had_shapes { if !is_empty || self.current_tile_had_shapes {
if self.options.is_interactive_transform() { if self.options.is_interactive_transform() {
// During drag, avoid snapshot-based caching. Draw Current directly // During drag, avoid snapshot-based caching. Draw Current directly
// into Target (and Cache) to reduce stalls. // into Target (and Cache, for the debug views) to reduce stalls.
let draw_on_cache = if self.options.is_debug_visible() {
surfaces::DrawOnCache::Yes
} else {
surfaces::DrawOnCache::No
};
self.surfaces.draw_current_tile_into_backbuffer( self.surfaces.draw_current_tile_into_backbuffer(
&tile_rect, &tile_rect,
self.background_color, self.background_color,
surfaces::DrawOnCache::Yes, draw_on_cache,
); );
} else { } else {
self.apply_render_to_final_canvas()?; self.apply_render_to_final_canvas()?;

View File

@ -88,93 +88,35 @@ impl GpuState {
None None
} }
fn delete_gl_texture(&mut self, texture_id: gl::types::GLuint) -> bool { pub fn create_surface_with_isize(&mut self, size: ISize) -> Result<skia::Surface> {
unsafe { self.create_surface_with_dimensions(size.width, size.height)
gl::DeleteTextures(1, &texture_id);
gl::GetError() == 0
}
}
fn create_gl_texture(&mut self, width: i32, height: i32) -> gl::types::GLuint {
let mut texture_id: gl::types::GLuint = 0;
unsafe {
gl::GenTextures(1, &mut texture_id);
gl::BindTexture(gl::TEXTURE_2D, texture_id);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MIN_FILTER, gl::LINEAR as i32);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_MAG_FILTER, gl::LINEAR as i32);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_S, gl::CLAMP_TO_EDGE as i32);
gl::TexParameteri(gl::TEXTURE_2D, gl::TEXTURE_WRAP_T, gl::CLAMP_TO_EDGE as i32);
gl::TexImage2D(
gl::TEXTURE_2D,
0,
gl::RGBA8 as i32,
width,
height,
0,
gl::RGBA,
gl::UNSIGNED_BYTE,
std::ptr::null(),
);
}
texture_id
}
pub fn delete_surface(&mut self, surface: &mut skia::Surface) -> bool {
let Some(texture) = skia::gpu::surfaces::get_backend_texture(
surface,
skia_safe::surface::BackendHandleAccess::FlushRead,
) else {
return false;
};
let Some(texture_info) = gpu::backend_textures::get_gl_texture_info(&texture) else {
return false;
};
self.delete_gl_texture(texture_info.id)
}
pub fn create_surface_with_isize(
&mut self,
label: String,
size: ISize,
) -> Result<skia::Surface> {
self.create_surface_with_dimensions(label, size.width, size.height)
} }
pub fn create_surface_with_dimensions( pub fn create_surface_with_dimensions(
&mut self, &mut self,
label: String,
width: i32, width: i32,
height: i32, height: i32,
) -> Result<skia::Surface> { ) -> Result<skia::Surface> {
let backend_texture = unsafe { let image_info = skia::ImageInfo::new(
let texture_id = self.create_gl_texture(width, height); (width, height),
let texture_info = TextureInfo { skia::ColorType::RGBA8888,
target: gl::TEXTURE_2D, skia::AlphaType::Premul,
id: texture_id, None,
format: gl::RGBA8, );
protected: skia::gpu::Protected::No,
};
gpu::backend_textures::make_gl((width, height), gpu::Mipmapped::No, texture_info, label)
};
let surface = gpu::surfaces::wrap_backend_texture( gpu::surfaces::render_target(
&mut self.context, &mut self.context,
&backend_texture, gpu::Budgeted::No,
&image_info,
None,
gpu::SurfaceOrigin::BottomLeft, gpu::SurfaceOrigin::BottomLeft,
None, None,
skia::ColorType::RGBA8888, false,
None,
None, None,
) )
.ok_or(Error::CriticalError( .ok_or(Error::CriticalError(
"Failed to create Skia surface".to_string(), "Failed to create Skia surface".to_string(),
))?; ))
Ok(surface)
} }
/// Create a Skia surface that will be used for rendering. /// Create a Skia surface that will be used for rendering.

View File

@ -165,8 +165,7 @@ impl DocAtlas {
pub fn try_new() -> Result<Self> { pub fn try_new() -> Result<Self> {
// Keep atlas as a regular surface like the rest. Start with a tiny // Keep atlas as a regular surface like the rest. Start with a tiny
// transparent surface and grow it on demand. // transparent surface and grow it on demand.
let mut surface = let mut surface = get_gpu_state().create_surface_with_dimensions(1, 1)?;
get_gpu_state().create_surface_with_dimensions("atlas".to_string(), 1, 1)?;
surface.canvas().clear(skia::Color::TRANSPARENT); surface.canvas().clear(skia::Color::TRANSPARENT);
@ -276,8 +275,7 @@ impl DocAtlas {
return Ok(()); return Ok(());
} }
let mut new_surface = let mut new_surface = gpu_state.create_surface_with_dimensions(new_w, new_h)?;
gpu_state.create_surface_with_dimensions("atlas".to_string(), new_w, new_h)?;
new_surface.canvas().clear(skia::Color::TRANSPARENT); new_surface.canvas().clear(skia::Color::TRANSPARENT);
// Copy old atlas into the new one with offset. // Copy old atlas into the new one with offset.
@ -307,7 +305,6 @@ impl DocAtlas {
self.origin = skia::Point::new(new_left, new_top); self.origin = skia::Point::new(new_left, new_top);
self.size = skia::ISize::new(new_w, new_h); self.size = skia::ISize::new(new_w, new_h);
self.scale = new_scale; self.scale = new_scale;
gpu_state.delete_surface(&mut self.surface);
self.surface = new_surface; self.surface = new_surface;
Ok(()) Ok(())
} }
@ -528,35 +525,25 @@ impl Surfaces {
let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4); let margins = skia::ISize::new(extra_tile_dims.width / 4, extra_tile_dims.height / 4);
let target = gpu_state.create_target_surface(width, height)?; let target = gpu_state.create_target_surface(width, height)?;
let filter = gpu_state.create_surface_with_isize("filter".to_string(), extra_tile_dims)?; let filter = gpu_state.create_surface_with_isize(extra_tile_dims)?;
let cache = gpu_state.create_surface_with_dimensions("cache".to_string(), width, height)?; let cache = gpu_state.create_surface_with_dimensions(width, height)?;
let backbuffer = let backbuffer = gpu_state.create_surface_with_dimensions(width, height)?;
gpu_state.create_surface_with_dimensions("backbuffer".to_string(), width, height)?;
let max_texture_size = gpu_state.max_texture_size(); let max_texture_size = gpu_state.max_texture_size();
let tile_atlas = gpu_state.create_surface_with_dimensions( let tile_atlas =
"tile_atlas".to_string(), gpu_state.create_surface_with_dimensions(max_texture_size, max_texture_size)?;
max_texture_size,
max_texture_size,
)?;
let current = let current = gpu_state.create_surface_with_isize(extra_tile_dims)?;
gpu_state.create_surface_with_isize("current".to_string(), extra_tile_dims)?;
let drop_shadows = let drop_shadows = gpu_state.create_surface_with_isize(extra_tile_dims)?;
gpu_state.create_surface_with_isize("drop_shadows".to_string(), extra_tile_dims)?; let inner_shadows = gpu_state.create_surface_with_isize(extra_tile_dims)?;
let inner_shadows = let text_drop_shadows = gpu_state.create_surface_with_isize(extra_tile_dims)?;
gpu_state.create_surface_with_isize("inner_shadows".to_string(), extra_tile_dims)?; let shape_fills = gpu_state.create_surface_with_isize(extra_tile_dims)?;
let text_drop_shadows = gpu_state let shape_strokes = gpu_state.create_surface_with_isize(extra_tile_dims)?;
.create_surface_with_isize("text_drop_shadows".to_string(), extra_tile_dims)?; let export = gpu_state.create_surface_with_isize(extra_tile_dims)?;
let shape_fills =
gpu_state.create_surface_with_isize("shape_fills".to_string(), extra_tile_dims)?;
let shape_strokes =
gpu_state.create_surface_with_isize("shape_strokes".to_string(), extra_tile_dims)?;
let export = gpu_state.create_surface_with_isize("export".to_string(), extra_tile_dims)?;
let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height)?; let ui = gpu_state.create_surface_with_dimensions(width, height)?;
let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height)?; let debug = gpu_state.create_surface_with_dimensions(width, height)?;
let tiles = TileTextureCache::new(tile_atlas.width(), tile_atlas.height()); let tiles = TileTextureCache::new(tile_atlas.width(), tile_atlas.height());
let atlas = DocAtlas::try_new()?; let atlas = DocAtlas::try_new()?;
@ -677,12 +664,10 @@ impl Surfaces {
)); ));
canvas.scale((s / scale, s / scale)); canvas.scale((s / scale, s / scale));
self.atlas.surface.draw( let sampling = skia::SamplingOptions::new(skia::FilterMode::Linear, skia::MipmapMode::None);
canvas, self.atlas
(0.0, 0.0), .surface
self.sampling_options, .draw(canvas, (0.0, 0.0), sampling, Some(&skia::Paint::default()));
Some(&skia::Paint::default()),
);
canvas.restore(); canvas.restore();
} }

View File

@ -50,18 +50,16 @@ impl State {
.to_string(), .to_string(),
)); ));
} }
self.saved_shapes = Some(self.shapes.clone()); self.saved_shapes = Some(std::mem::take(&mut self.shapes));
self.shapes = ShapesPool::new();
Ok(()) Ok(())
} }
// Disposes of the temporary shapes pool restoring the normal pool // Disposes of the temporary shapes pool restoring the normal pool
// Will panic if a there is no temporary pool. // Will panic if a there is no temporary pool.
pub fn end_temp_objects(&mut self) -> Result<()> { pub fn end_temp_objects(&mut self) -> Result<()> {
self.shapes = self.saved_shapes.clone().ok_or(Error::CriticalError( self.shapes = self.saved_shapes.take().ok_or(Error::CriticalError(
"Tried to end temp objects but not content to be restored is present".to_string(), "Tried to end temp objects but not content to be restored is present".to_string(),
))?; ))?;
self.saved_shapes = None;
Ok(()) Ok(())
} }