Improve canvas snapshot and nested drop shadows calculation (#11766)

*  Capture canvas snapshot only when the renderer is idle

*  Skip descendant silhouettes for frames with nested drop shadows

* 🔧 Add a profiling build mode for render-wasm
This commit is contained in:
Elena Torró 2026-09-18 09:21:52 +02:00 committed by GitHub
parent fe89e9e52c
commit 8124ecf148
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 161 additions and 84 deletions

View File

@ -105,7 +105,10 @@
(defonce transition-tiles-handler* (atom nil))
(defonce snapshot-tiles-handler* (atom nil))
(def ^:private snapshot-capture-debounce-ms 250)
(def ^:private snapshot-capture-debounce-ms 1000)
;; Guards against stacking readbacks: one capture may still be in flight when
;; the next `tiles-complete` arrives.
(defonce ^:private snapshot-capture-in-flight? (atom false))
(defn initialized?
@ -214,47 +217,6 @@
(f))
#js {:once true}))
(defn capture-canvas-snapshot
"Captures the viewport canvas into `wasm/canvas-snapshot` (an `ImageBitmap`)
and closes the replaced snapshot unless the transition overlay is still
showing it (a replaced snapshot can never become displayed again, so closing
it is safe). Returns a promise resolving to the bitmap (or nil)."
[]
(let [^js prev wasm/canvas-snapshot]
(-> (webgl/capture-canvas-snapshot)
(p/then (fn [^js bitmap]
(when (and (some? prev)
(some? bitmap)
(not (identical? prev bitmap))
(not (identical? prev @transition-image*)))
(.close prev))
bitmap)))))
(defonce ^:private schedule-canvas-snapshot-capture!
(fns/debounce
(fn []
(when (and (initialized?)
(some? wasm/canvas))
(-> (capture-canvas-snapshot)
(p/catch (fn [_] nil)))))
snapshot-capture-debounce-ms))
(defn- start-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(let [handler (fn [_] (schedule-canvas-snapshot-capture!))]
(reset! snapshot-tiles-handler* handler)
(.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler)))
(defn- stop-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! snapshot-tiles-handler* nil)
(when-let [cancel (unchecked-get schedule-canvas-snapshot-capture! "cancel")]
(cancel)))
(defn text-editor-wasm?
[]
(or (contains? cf/flags :feature-text-editor-wasm)
@ -623,6 +585,75 @@
[]
@pending-render)
(defn capture-canvas-snapshot
"Captures the viewport canvas into `wasm/canvas-snapshot` (an `ImageBitmap`)
and closes the replaced snapshot unless the transition overlay is still
showing it (a replaced snapshot can never become displayed again, so closing
it is safe). Returns a promise resolving to the bitmap (or nil)."
[]
(let [^js prev wasm/canvas-snapshot]
(-> (webgl/capture-canvas-snapshot)
(p/then (fn [^js bitmap]
(when (and (some? prev)
(some? bitmap)
(not (identical? prev bitmap))
(not (identical? prev @transition-image*)))
(.close prev))
bitmap)))))
(defn- canvas-snapshot-capture-idle?
"The capture is a full GPU readback of the canvas. On Firefox (out-of-process
WebGL) `createImageBitmap` blocks the main thread on a synchronous
`Msg_ReadPixels` until the whole queued GL command backlog has drained, so it
must never run while more frames are still coming."
[]
(and (initialized?)
(some? wasm/canvas)
(not @snapshot-capture-in-flight?)
(not @view-interaction-active?)
(not @shapes-loading?)
(not @page-transition?)
(not (render-pending?))))
(declare schedule-canvas-snapshot-capture!)
(defn- capture-canvas-snapshot-when-idle!
[]
;; Retry instead of waiting for another `tiles-complete`: WASM notifies once
;; per render cycle (`ViewportReady` sets `viewport_presented`, so the
;; interest-ring `Full` stays silent), and that notification is what armed us.
(if-not (canvas-snapshot-capture-idle?)
(schedule-canvas-snapshot-capture!)
(timers/schedule-on-idle
(fn []
(if-not (canvas-snapshot-capture-idle?)
(schedule-canvas-snapshot-capture!)
(do
(reset! snapshot-capture-in-flight? true)
(-> (capture-canvas-snapshot)
(p/catch (fn [_] nil))
(p/finally (fn [_ _] (reset! snapshot-capture-in-flight? false))))))))))
(defonce ^:private schedule-canvas-snapshot-capture!
(fns/debounce capture-canvas-snapshot-when-idle! snapshot-capture-debounce-ms))
(defn- start-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(let [handler (fn [_] (schedule-canvas-snapshot-capture!))]
(reset! snapshot-tiles-handler* handler)
(.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler)))
(defn- stop-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! snapshot-tiles-handler* nil)
(reset! snapshot-capture-in-flight? false)
(when-let [cancel (unchecked-get schedule-canvas-snapshot-capture! "cancel")]
(cancel)))
(defn- stop-progressive-render!
"Cancel the pending tile-pass rAF and invalidate any follow-ups it may schedule."
[]

View File

@ -136,9 +136,11 @@ void main() {
(defn capture-canvas-snapshot
"Captures the current viewport canvas as an `ImageBitmap` and stores it in
`wasm/canvas-snapshot`. Unlike `canvas.toBlob` (which does a synchronous GPU
readback plus PNG encoding on the main thread), `createImageBitmap` resolves
asynchronously and stays on the GPU in accelerated browsers.
`wasm/canvas-snapshot`. It avoids `canvas.toBlob`'s PNG encoding, but the
readback itself is not free: on Firefox, `createImageBitmap` on a WebGL canvas
takes a synchronous back-buffer snapshot over IPC before the promise is even
created, which blocks the main thread until the queued GL commands drain.
Callers must only invoke it when the renderer is idle.
Returns a promise resolving to the ImageBitmap (or nil)."
[]

View File

@ -46,6 +46,15 @@ lto = "fat"
strip = true
codegen-units = 1
[profile.profiling]
inherits = "release"
strip = false
# Fat LTO inlines the shadow helpers into their caller, so a profile cannot
# attribute cost to them. Thin LTO keeps those boundaries; the code is still
# optimized, so the proportions stay representative.
lto = "thin"
codegen-units = 16
[profile.size]
inherits = "release"
opt-level = "z"

View File

@ -63,7 +63,14 @@ export CARGO_PARAMS="${@:2}";
export CARGO_PROFILE_DIR="debug";
if [ "$BUILD_MODE" = "release" ]; then
if [ "$BUILD_MODE" = "profiling" ]; then
# Optimized like release, but keeps the wasm name section so the Firefox
# profiler can resolve Rust frames (release strips them). `--profiling-funcs`
# preserves function names through wasm-opt without pulling in DWARF.
export CARGO_PARAMS="--profile profiling $CARGO_PARAMS";
export CARGO_PROFILE_DIR="profiling";
export EMCC_CFLAGS="-O3 -sASSERTIONS=0 --profiling-funcs $EMCC_CFLAGS";
elif [ "$BUILD_MODE" = "release" ]; then
case "$RENDER_TARGET" in
frontend)
export CARGO_PARAMS="--release $CARGO_PARAMS";

View File

@ -3486,7 +3486,7 @@ impl RenderState {
};
let recursive = element.is_recursive();
let use_direct_container_shadow = element.uses_direct_container_drop_shadow(tree, scale);
let use_direct_container_shadow = element.uses_direct_container_drop_shadow(tree);
let mut rendered_any = false;
for shadow in element.drop_shadows_visible() {
if !shadow.is_perceptible_at_scale_for(scale, recursive) {
@ -3518,7 +3518,7 @@ impl RenderState {
None,
target_surface,
)?;
if !element.container_fill_covers_shadow_descendants(tree, scale) {
if !element.container_fill_covers_shadow_descendants(tree) {
self.render_drop_shadow_child_silhouettes(
element,
tree,

View File

@ -1115,7 +1115,7 @@ impl Shape {
}
fn own_extrect_bounds(&self) -> Bounds {
self.expand_own_bounds(self.own_base_bounds())
self.expand_own_bounds(self.own_base_bounds(), true)
}
/// The shape's own geometry bounds, before stroke/shadow/blur margins.
@ -1143,11 +1143,23 @@ impl Shape {
}
}
fn expand_own_bounds(&self, bounds: Bounds) -> Bounds {
fn expand_own_bounds(&self, bounds: Bounds, include_shadows: bool) -> Bounds {
let max_stroke = Stroke::max_bounds_width(self.strokes.iter(), self.is_open());
let mut bounds = self.apply_stroke_bounds(bounds, max_stroke);
bounds = self.apply_shadow_bounds(bounds);
bounds = self.apply_blur_bounds(bounds);
if include_shadows {
bounds = self.apply_shadow_bounds(bounds);
}
self.apply_blur_bounds(bounds)
}
/// `own_base_bounds` for layer purposes: a text is never tighter than its selrect.
fn own_layer_base_bounds(&self) -> Bounds {
let mut bounds = self.own_base_bounds();
if matches!(self.shape_type, Type::Text(_)) {
let mut rect = bounds.to_rect();
rect.join(self.selrect);
bounds = Bounds::from_rect(&rect);
}
bounds
}
@ -1156,15 +1168,18 @@ impl Shape {
/// first). Includes shadow/blur margins, so it is also a valid input bound
/// for a layer whose paint carries an image filter.
pub fn layer_bounds(&self) -> math::Rect {
let mut bounds = self.own_base_bounds();
self.expand_own_bounds(self.own_layer_base_bounds(), true)
.to_rect()
}
if matches!(self.shape_type, Type::Text(_)) {
let mut rect = bounds.to_rect();
rect.join(self.selrect);
bounds = Bounds::from_rect(&rect);
/// Geometry, strokes and layer blur in world space, without this shape's own
/// drop shadows: those belong to the shape, not to an ancestor's shadow mask.
pub fn silhouette_rect(&self) -> math::Rect {
let mut bounds = self.expand_own_bounds(self.own_layer_base_bounds(), false);
if !self.transform.is_identity() {
bounds.transform_mut(&self.centered_transform());
}
self.expand_own_bounds(bounds).to_rect()
bounds.to_rect()
}
fn calculate_extrect_uncached(&self, shapes_pool: ShapesPoolRef, scale: f32) -> math::Rect {
@ -1897,7 +1912,7 @@ impl Shape {
/// The fast path draws fill geometry only. On the slow path, visible strokes also
/// contribute to the shadow silhouette, so frames with outer/center strokes can
/// look slightly narrower here. We keep them eligible anyway for performance.
pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef, scale: f32) -> bool {
pub fn uses_direct_container_drop_shadow(&self, tree: ShapesPoolRef) -> bool {
if !matches!(self.shape_type, Type::Frame(_)) {
return false;
}
@ -1918,17 +1933,13 @@ impl Shape {
return !self.descendants_have_drop_shadows(tree);
}
self.descendants_contained_for_frame_shadow(tree, scale, self.selrect())
self.descendants_contained_for_frame_shadow(tree, self.selrect())
}
/// When true, the container's own fill shadow mask is enough and descendant
/// silhouettes can be skipped (same geometry assumption as the direct path).
pub fn container_fill_covers_shadow_descendants(
&self,
tree: ShapesPoolRef,
scale: f32,
) -> bool {
self.has_fills() && self.descendants_contained_for_frame_shadow(tree, scale, self.selrect())
pub fn container_fill_covers_shadow_descendants(&self, tree: ShapesPoolRef) -> bool {
self.has_fills() && self.descendants_contained_for_frame_shadow(tree, self.selrect())
}
fn descendants_have_drop_shadows(&self, tree: ShapesPoolRef) -> bool {
@ -1952,13 +1963,8 @@ impl Shape {
fn descendants_contained_for_frame_shadow(
&self,
tree: ShapesPoolRef,
scale: f32,
bounds: math::Rect,
) -> bool {
if self.descendants_have_drop_shadows(tree) {
return false;
}
const MARGIN: f32 = 0.5;
for child_id in self.children_ids_iter(false) {
let Some(child) = tree.get(child_id) else {
@ -1967,13 +1973,10 @@ impl Shape {
if child.hidden {
continue;
}
let child_extrect = child.extrect(tree, scale);
if !rect_contains_with_margin(bounds, child_extrect, MARGIN) {
if !rect_contains_with_margin(bounds, child.silhouette_rect(), MARGIN) {
return false;
}
if child.is_recursive()
&& !child.descendants_contained_for_frame_shadow(tree, scale, bounds)
{
if child.is_recursive() && !child.descendants_contained_for_frame_shadow(tree, bounds) {
return false;
}
}
@ -2240,7 +2243,7 @@ mod tests {
] {
let (pool, frame_id) = frame_with_fill_and_child(fill, opacity);
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0));
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
}
@ -2263,7 +2266,7 @@ mod tests {
}
let frame = pool.get(&frame_id).expect("frame");
assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0));
assert!(!frame.uses_direct_container_drop_shadow(&pool));
}
#[test]
@ -2291,7 +2294,7 @@ mod tests {
}
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0));
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
#[test]
@ -2319,8 +2322,8 @@ mod tests {
}
let frame = pool.get(&frame_id).expect("frame");
assert!(!frame.uses_direct_container_drop_shadow(&pool, 1.0));
assert!(!frame.container_fill_covers_shadow_descendants(&pool, 1.0));
assert!(!frame.uses_direct_container_drop_shadow(&pool));
assert!(!frame.container_fill_covers_shadow_descendants(&pool));
}
#[test]
@ -2328,7 +2331,32 @@ mod tests {
let (pool, frame_id) =
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0);
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.container_fill_covers_shadow_descendants(&pool, 1.0));
assert!(frame.container_fill_covers_shadow_descendants(&pool));
}
#[test]
fn unclipped_frame_with_contained_shadowed_child_covers_descendants() {
let (mut pool, frame_id) =
frame_with_fill_and_child(Fill::Solid(SolidColor(skia::Color::WHITE)), 1.0);
let child_id = pool.get(&frame_id).expect("frame").children[0];
pool.get_mut(&frame_id).expect("frame").set_clip(false);
{
let child = pool.get_mut(&child_id).expect("child");
// Shadow reaches past the frame; the geometry stays inside.
child.add_shadow(Shadow::new(
skia::Color::BLACK,
20.0,
0.0,
(0.0, 20.0),
ShadowStyle::Drop,
false,
));
}
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.container_fill_covers_shadow_descendants(&pool));
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
#[test]
@ -2351,6 +2379,6 @@ mod tests {
}
let frame = pool.get(&frame_id).expect("frame");
assert!(frame.uses_direct_container_drop_shadow(&pool, 1.0));
assert!(frame.uses_direct_container_drop_shadow(&pool));
}
}