diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 2a95950d07..8cfda5e150 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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." [] diff --git a/frontend/src/app/render_wasm/api/webgl.cljs b/frontend/src/app/render_wasm/api/webgl.cljs index 862374ec01..5fedee789f 100644 --- a/frontend/src/app/render_wasm/api/webgl.cljs +++ b/frontend/src/app/render_wasm/api/webgl.cljs @@ -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)." [] diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 3c467c1fa2..8c0be81049 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -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" diff --git a/render-wasm/_build_env b/render-wasm/_build_env index e034580dfd..40e1e83777 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -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"; diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d5595ec3f5..83a6c6d0bc 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 0a5981ccb8..b59ed17bce 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -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)); } }