diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs index 7de082b675..9ae5d63994 100644 --- a/frontend/src/app/main/render_viewer_wasm.cljs +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -12,6 +12,7 @@ [app.render-wasm.wasm :as wasm] [app.util.dom :as dom] [app.util.timers :as ts] + [app.util.webapi :as webapi] [goog.events :as events] [promesa.core :as p] [rumext.v2 :as mf])) @@ -30,14 +31,16 @@ (atom {:os-canvas nil :page-key nil :canvas-w 0 - :canvas-h 0})) + :canvas-h 0 + :dpr 1})) (defn- reset-viewer-snapshot! [] (reset! viewer-snapshot {:os-canvas nil :page-key nil :canvas-w 0 - :canvas-h 0})) + :canvas-h 0 + :dpr 1})) (defn- draw-bitmap! [canvas os-canvas object-id vis-w vis-h finish] @@ -135,9 +138,12 @@ (resolve nil)) vis-w (.-width canvas) vis-h (.-height canvas) + dpr (wasm.api/get-dpr) snap @viewer-snapshot same-page? (and (some? page-key) (identical? page-key (:page-key snap))) - same-size? (and (= vis-w (:canvas-w snap)) (= vis-h (:canvas-h snap))) + same-size? (and (= vis-w (:canvas-w snap)) + (= vis-h (:canvas-h snap)) + (= dpr (:dpr snap))) os (:os-canvas snap) do-render! (fn [os-canvas] (viewer-do-render! page-objects canvas os-canvas object-id @@ -151,7 +157,7 @@ (do (when-not same-size? (wasm.api/resize-offscreen-canvas! os vis-w vis-h) - (swap! viewer-snapshot assoc :canvas-w vis-w :canvas-h vis-h)) + (swap! viewer-snapshot assoc :canvas-w vis-w :canvas-h vis-h :dpr dpr)) (do-render! os)) (let [os-canvas (js/OffscreenCanvas. vis-w vis-h)] (when (wasm.api/initialized?) @@ -162,7 +168,8 @@ {:os-canvas os-canvas :page-key page-key :canvas-w vis-w - :canvas-h vis-h}) + :canvas-h vis-h + :dpr dpr}) (wasm.api/initialize-viewport page-objects scale size :background-opacity 0 @@ -192,50 +199,71 @@ (let [key (events/listen section "scroll" (fn [_] (sync!)))] #(events/unlistenByKey key)))))))) +(defn- use-viewer-dpr-key + "Bump a counter when browser zoom changes devicePixelRatio so WASM canvases + are resized like the workspace viewport." + [] + (let [dpr-key (mf/use-state 0)] + (mf/use-effect + (mf/deps []) + (fn [] + (webapi/on-dpr-change (fn [_] (swap! dpr-key inc))))) + @dpr-key)) + (defn- use-viewer-wasm-layers! [page-id page-objects size scale frame-id not-fixed-ref fixed-ref - not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids] - (mf/use-layout-effect - (mf/deps page-id page-objects size scale frame-id - not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) - (fn [] - (when (get page-objects frame-id) - (->> @wasm.api/module - (p/fmap - (fn [ready?] - (when ready? - (let [not-fixed-canvas (mf/ref-val not-fixed-ref) - fixed-canvas (mf/ref-val fixed-ref) - passes - (cond-> [] - not-fixed-canvas - (conj {:canvas not-fixed-canvas - :opts (cond-> {} - (seq not-fixed-include-ids) - (assoc :include-ids not-fixed-include-ids))}) + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids delta dpr-key] + ;; The hot-areas SVG shifts every object by `-(size + delta)` so the frame + ;; `selrect` lands flush against the overlay snap side, ignoring the extra + ;; padding reserved for shadows/blur/strokes. Bake the same `delta` into the + ;; WASM view origin so the rendered canvas aligns with that SVG (otherwise it + ;; appears offset by the shadow margin). + (let [render-size (-> size + (update :x + (:x delta 0)) + (update :y + (:y delta 0)))] + (mf/use-layout-effect + (mf/deps page-id page-objects render-size scale frame-id dpr-key + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) + (fn [] + (when (get page-objects frame-id) + (->> @wasm.api/module + (p/fmap + (fn [ready?] + (when ready? + (let [not-fixed-canvas (mf/ref-val not-fixed-ref) + fixed-canvas (mf/ref-val fixed-ref) + passes + (cond-> [] + not-fixed-canvas + (conj {:canvas not-fixed-canvas + :opts (cond-> {} + (seq not-fixed-include-ids) + (assoc :include-ids not-fixed-include-ids))}) - (and fixed-canvas (seq fixed-include-ids)) - (conj {:canvas fixed-canvas - :opts (cond-> {:include-ids fixed-include-ids} - (seq fixed-clear-fills-ids) - (assoc :clear-fills-ids fixed-clear-fills-ids))}))] - (when (seq passes) - (enqueue-wasm-render! - (fn [] - (reduce (fn [chain {:keys [canvas opts]}] - (p/then chain - #(render-viewer-frame* page-id page-objects - canvas size scale frame-id - nil opts))) - (p/resolved nil) - passes))))))))))))) + (and fixed-canvas (seq fixed-include-ids)) + (conj {:canvas fixed-canvas + :opts (cond-> {:include-ids fixed-include-ids} + (seq fixed-clear-fills-ids) + (assoc :clear-fills-ids fixed-clear-fills-ids))}))] + (when (seq passes) + (enqueue-wasm-render! + (fn [] + (reduce (fn [chain {:keys [canvas opts]}] + (p/then chain + #(render-viewer-frame* page-id page-objects + canvas render-size scale frame-id + nil opts))) + (p/resolved nil) + passes)))))))))))))) (defn use-viewer-wasm-viewport! "WASM render passes and fixed-scroll DOM sync for the viewer viewport." [page-id page-objects size scale frame-id not-fixed-ref fixed-ref fixed-scroll-layer-ref - not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids] + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids delta] (use-fixed-scroll-sync! (some? fixed-scroll-layer-ref) fixed-scroll-layer-ref) - (use-viewer-wasm-layers! page-id page-objects size scale frame-id - not-fixed-ref fixed-ref - not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids)) + (let [dpr-key (use-viewer-dpr-key)] + (use-viewer-wasm-layers! page-id page-objects size scale frame-id + not-fixed-ref fixed-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids + delta dpr-key))) diff --git a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs index 50f97f5cbf..23d5b13490 100644 --- a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs @@ -12,12 +12,17 @@ [app.main.render-viewer-wasm :as rwv] [app.main.ui.viewer.shapes :as shapes] [app.main.ui.viewer.viewport-common :as vpc] + [app.render-wasm.api :as wasm.api] [rumext.v2 :as mf])) (defn- canvas-dimensions + "Physical canvas pixels (CSS layout size × DPR), matching the workspace WASM path." [scale size] - {:width (js/Math.round (* scale (:base-width size))) - :height (js/Math.round (* scale (:base-height size)))}) + (let [css-w (js/Math.round (* scale (:base-width size))) + css-h (js/Math.round (* scale (:base-height size))) + dpr (wasm.api/get-dpr)] + {:width (js/Math.round (* css-w dpr)) + :height (js/Math.round (* css-h dpr))})) (mf/defc wasm-hotspots-svg* [{:keys [vbox size class prepared prepared-all prepared-frame shape-filter]}] @@ -145,7 +150,8 @@ page-id objects size scale frame-id not-fixed-wasm-ref fixed-wasm-ref (when has-fixed? fixed-layer-ref) - not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids + delta) [:& (mf/provider shapes/base-frame-ctx) {:value (get prepared-all (:id base))} [:& (mf/provider shapes/frame-offset-ctx) {:value offset} diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a1a0a2f6bf..03b399c02f 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1920,15 +1920,6 @@ (h/call wasm/internal-module "_set_view_end") (reset! view-interaction-active? false))) -(defn resize-offscreen-canvas! - "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and - update the WASM render surfaces accordingly (via `_resize_viewbox`). The - design state (shape pool) is preserved so `set-objects` is not needed again." - [canvas new-physical-w new-physical-h] - (set! (.-width canvas) new-physical-w) - (set! (.-height canvas) new-physical-h) - (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))) - (defn- debug-flags [] (cond-> 0 @@ -1939,6 +1930,22 @@ (contains? cf/flags :render-wasm-info) (bit-or 2r00000000000000000000000000001000))) +(defn set-render-options! + "Updates WASM render options with a new DPR value." + [new-dpr] + (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr)) + +(defn resize-offscreen-canvas! + "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and + update the WASM render surfaces accordingly (via `_resize_viewbox`). The + design state (shape pool) is preserved so `set-objects` is not needed again." + [canvas new-physical-w new-physical-h] + (let [dpr (get-dpr)] + (set! (.-width canvas) new-physical-w) + (set! (.-height canvas) new-physical-h) + (set-render-options! dpr) + (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr)))) + (defn- wasm-get-numeric-value [name] (when-let [raw (let [p (rt/get-params @st/state)] @@ -1953,11 +1960,6 @@ (let [setter-name (str/concat "_set_" (name param-name))] (h/call wasm/internal-module setter-name value)))) -(defn set-render-options! - "Updates WASM render options with a new DPR value." - [new-dpr] - (h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr)) - (defn- canvas-css-size "Return canvas size in CSS pixels. diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b613ec5586..8d59339b65 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -907,6 +907,14 @@ impl RenderState { pub fn reset_canvas(&mut self) { self.surfaces.reset(self.background_color); + self.surfaces.clear_backbuffer(self.background_color); + self.surfaces.clear_target(self.background_color); + } + + /// Drop cached tile textures before a one-shot `render_sync_shape` render. + pub fn prepare_sync_shape_render(&mut self) { + self.surfaces.clear_tile_atlas(); + self.surfaces.invalidate_tile_cache(); } /// NOTE: @@ -1085,11 +1093,6 @@ impl RenderState { self.include_filter.is_some() } - fn reset_viewer_masked_surfaces(&mut self) { - self.surfaces.clear_backbuffer(self.background_color); - self.surfaces.clear_tile_atlas(); - } - /// True when the shape or any descendant is whitelisted. pub fn shape_visible_in_include_filter(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { let Some(ref include) = self.include_filter else { @@ -2192,13 +2195,6 @@ impl RenderState { self.interactive_target_seeded = false; } - // 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. - if self.viewer_masked_pass() { - self.reset_viewer_masked_surfaces(); - } - let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 1995ffe288..9ca681841d 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -80,7 +80,9 @@ impl State { } pub fn render_sync_shape(&mut self, id: &Uuid, timestamp: i32) -> Result { - get_render_state().start_render_loop(Some(id), &self.shapes, timestamp, true) + let render_state = get_render_state(); + render_state.prepare_sync_shape_render(); + render_state.start_render_loop(Some(id), &self.shapes, timestamp, true) } pub fn render_shape_pixels(