mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
⚡ 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:
parent
fe89e9e52c
commit
8124ecf148
@ -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."
|
||||
[]
|
||||
|
||||
@ -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)."
|
||||
[]
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user