🐛 Fix async text rendering on tiles (#10504)

This commit is contained in:
Elena Torró 2026-07-01 11:09:57 +02:00 committed by GitHub
parent 05e9c68a74
commit c8f586a197
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 117 additions and 47 deletions

View File

@ -1424,18 +1424,20 @@
(set-shape-layout shape) (set-shape-layout shape)
(set-layout-data shape) (set-layout-data shape)
(let [is-text? (= type :text) (let [is-text? (= type :text)
pending_thumbnails (into [] (concat text-content-pending (when is-text? (set-shape-text-content id content))
(when is-text? (set-shape-text-content id content)) pending-thumbnails (into [] (concat
text-content-pending
(when is-text? (set-shape-text-images id content true)) (when is-text? (set-shape-text-images id content true))
(set-shape-fills id fills true) (set-shape-fills id fills true)
(set-shape-strokes id strokes true))) (set-shape-strokes id strokes true)))
pending_full (into [] (concat pending-full (into [] (concat
(when is-text? (set-shape-text-images id content false)) (when is-text? (set-shape-text-images id content false))
(set-shape-fills id fills false) (set-shape-fills id fills false)
(set-shape-strokes id strokes false)))] (set-shape-strokes id strokes false)))]
(perf/end-measure "set-object") (perf/end-measure "set-object")
{:thumbnails pending_thumbnails {:thumbnails pending-thumbnails
:full pending_full})))) :full pending-full
:font-pending-ids (if (some :callback text-content-pending) [id] [])}))))
(defn- update-text-layouts (defn- update-text-layouts
"Synchronously update text layouts for all shapes and send rect updates "Synchronously update text layouts for all shapes and send rect updates
@ -1443,8 +1445,29 @@
[text-ids] [text-ids]
(run! f/update-text-layout text-ids)) (run! f/update-text-layout text-ids))
(defn- force-update-text-layouts
"Like update-text-layouts but forces a relayout. Use after pending fonts
resolve so layouts (and the extrect/tiles derived from them) use real glyph
metrics instead of fallback-font estimates."
[text-ids]
(run! f/force-update-text-layout text-ids))
(defn- relayout-after-fonts!
"Relayout text shapes once their pending fonts have resolved. Shapes in
`font-pending-ids` had a font fetched, so they get a forced relayout to pick
up the real glyph metrics; the remaining text shapes get a normal layout."
[shapes font-pending-ids]
(let [force-ids (set font-pending-ids)
text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)
forced (filterv force-ids text-ids)
rest-ids (filterv (complement force-ids) text-ids)]
(when (seq forced)
(force-update-text-layouts forced))
(when (seq rest-ids)
(update-text-layouts rest-ids))))
(defn process-pending (defn process-pending
[shapes thumbnails full on-complete] [shapes thumbnails full font-pending-ids on-complete]
(let [pending-thumbnails (let [pending-thumbnails
(d/index-by :key :callback thumbnails) (d/index-by :key :callback thumbnails)
@ -1468,11 +1491,7 @@
(rx/catch #(rx/empty)))) (rx/catch #(rx/empty))))
(rx/subs! (rx/subs!
(fn [_] (fn [_]
;; Fonts are now loaded — recompute text layouts so Skia (relayout-after-fonts! shapes font-pending-ids)
;; uses the real metrics instead of fallback-font estimates.
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)]
(when (seq text-ids)
(update-text-layouts text-ids)))
(request-render "images-loaded")) (request-render "images-loaded"))
noop-fn noop-fn
(fn [] (when (fn? on-complete) (on-complete))))) (fn [] (when (fn? on-complete) (on-complete)))))
@ -1481,8 +1500,8 @@
(defn process-object (defn process-object
[shape] [shape]
(let [{:keys [thumbnails full]} (set-object shape)] (let [{:keys [thumbnails full font-pending-ids]} (set-object shape)]
(process-pending [shape] thumbnails full noop-fn))) (process-pending [shape] thumbnails full font-pending-ids noop-fn)))
(defn process-objects (defn process-objects
"Like process-object but for multiple shapes at once. Accumulates all "Like process-object but for multiple shapes at once. Accumulates all
@ -1491,37 +1510,43 @@
just the first shape that triggered the fetch." just the first shape that triggered the fetch."
[shapes] [shapes]
(let [total-shapes (count shapes) (let [total-shapes (count shapes)
{:keys [thumbnails full]} {:keys [thumbnails full font-pending-ids]}
(loop [index 0 thumbnails-acc (transient []) full-acc (transient [])] (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])]
(if (< index total-shapes) (if (< index total-shapes)
(let [shape (nth shapes index) (let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)] {:keys [thumbnails full font-pending-ids]} (set-object shape)]
(recur (inc index) (recur (inc index)
(reduce conj! thumbnails-acc thumbnails) (reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full))) (reduce conj! full-acc full)
{:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))] (reduce conj! font-acc font-pending-ids)))
(process-pending shapes thumbnails full noop-fn))) {:thumbnails (persistent! thumbnails-acc)
:full (persistent! full-acc)
:font-pending-ids (persistent! font-acc)}))]
(process-pending shapes thumbnails full font-pending-ids noop-fn)))
(defn- process-shapes-chunk (defn- process-shapes-chunk
"Process shapes starting at `start-index` until the time budget is exhausted. "Process shapes starting at `start-index` until the time budget is exhausted.
Returns {:thumbnails [...] :full [...] :next-index n}" Returns {:thumbnails [...] :full [...] :font-pending-ids [...] :next-index n}"
[shapes start-index thumbnails-acc full-acc] [shapes start-index thumbnails-acc full-acc font-pending-acc]
(let [total (count shapes) (let [total (count shapes)
deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)] deadline (+ (js/performance.now) CHUNK_TIME_BUDGET_MS)]
(loop [index start-index (loop [index start-index
t-acc (transient thumbnails-acc) t-acc (transient thumbnails-acc)
f-acc (transient full-acc)] f-acc (transient full-acc)
fp-acc (transient font-pending-acc)]
(if (and (< index total) (if (and (< index total)
;; Check performance.now every 8 shapes to reduce overhead ;; Check performance.now every 8 shapes to reduce overhead
(or (pos? (bit-and (- index start-index) 7)) (or (pos? (bit-and (- index start-index) 7))
(<= (js/performance.now) deadline))) (<= (js/performance.now) deadline)))
(let [shape (nth shapes index) (let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)] {:keys [thumbnails full font-pending-ids]} (set-object shape)]
(recur (inc index) (recur (inc index)
(reduce conj! t-acc thumbnails) (reduce conj! t-acc thumbnails)
(reduce conj! f-acc full))) (reduce conj! f-acc full)
(reduce conj! fp-acc font-pending-ids)))
{:thumbnails (persistent! t-acc) {:thumbnails (persistent! t-acc)
:full (persistent! f-acc) :full (persistent! f-acc)
:font-pending-ids (persistent! fp-acc)
:next-index index})))) :next-index index}))))
(defn- set-objects-async (defn- set-objects-async
@ -1532,16 +1557,16 @@
(let [total-shapes (count shapes)] (let [total-shapes (count shapes)]
(p/create (p/create
(fn [resolve _reject] (fn [resolve _reject]
(letfn [(process-next-chunk [index thumbnails-acc full-acc] (letfn [(process-next-chunk [index thumbnails-acc full-acc font-pending-acc]
(if (< index total-shapes) (if (< index total-shapes)
;; Process one time-budgeted chunk ;; Process one time-budgeted chunk
(let [{:keys [thumbnails full next-index]} (let [{:keys [thumbnails full font-pending-ids next-index]}
(process-shapes-chunk shapes index (process-shapes-chunk shapes index
thumbnails-acc full-acc)] thumbnails-acc full-acc font-pending-acc)]
;; Yield to browser, then continue with next chunk ;; Yield to browser, then continue with next chunk
(-> (yield-to-browser) (-> (yield-to-browser)
(p/then (fn [_] (p/then (fn [_]
(process-next-chunk next-index thumbnails full))))) (process-next-chunk next-index thumbnails full font-pending-ids)))))
;; All chunks done - finalize ;; All chunks done - finalize
(do (do
(perf/end-measure "set-objects") (perf/end-measure "set-objects")
@ -1588,13 +1613,11 @@
(rx/reduce conj []))) (rx/reduce conj [])))
(rx/subs! (rx/subs!
(fn [_] (fn [_]
(let [text-ids (into [] (comp (filter cfh/text-shape?) (map :id)) shapes)] (relayout-after-fonts! shapes font-pending-acc)
(when (seq text-ids)
(update-text-layouts text-ids)))
(request-render "images-loaded")) (request-render "images-loaded"))
noop-fn noop-fn
noop-fn)))))))] noop-fn)))))))]
(process-next-chunk 0 [] [])))))) (process-next-chunk 0 [] [] []))))))
;; This is a version of process-pending that doesn't have sideffects ;; This is a version of process-pending that doesn't have sideffects
@ -1647,22 +1670,25 @@
"Synchronously process all shapes (for small shape counts)." "Synchronously process all shapes (for small shape counts)."
[shapes render-callback on-shapes-ready] [shapes render-callback on-shapes-ready]
(let [total-shapes (count shapes) (let [total-shapes (count shapes)
{:keys [thumbnails full]} {:keys [thumbnails full font-pending-ids]}
(loop [index 0 thumbnails-acc (transient []) full-acc (transient [])] (loop [index 0 thumbnails-acc (transient []) full-acc (transient []) font-acc (transient [])]
(if (< index total-shapes) (if (< index total-shapes)
(let [shape (nth shapes index) (let [shape (nth shapes index)
{:keys [thumbnails full]} (set-object shape)] {:keys [thumbnails full font-pending-ids]} (set-object shape)]
(recur (inc index) (recur (inc index)
(reduce conj! thumbnails-acc thumbnails) (reduce conj! thumbnails-acc thumbnails)
(reduce conj! full-acc full))) (reduce conj! full-acc full)
{:thumbnails (persistent! thumbnails-acc) :full (persistent! full-acc)}))] (reduce conj! font-acc font-pending-ids)))
{:thumbnails (persistent! thumbnails-acc)
:full (persistent! full-acc)
:font-pending-ids (persistent! font-acc)}))]
(perf/end-measure "set-objects") (perf/end-measure "set-objects")
(when on-shapes-ready (on-shapes-ready)) (when on-shapes-ready (on-shapes-ready))
;; Rebuild the tile index so _render knows which shapes ;; Rebuild the tile index so _render knows which shapes
;; map to which tiles after a page switch. ;; map to which tiles after a page switch.
(h/call wasm/internal-module "_set_view_end") (h/call wasm/internal-module "_set_view_end")
(reset! view-interaction-active? false) (reset! view-interaction-active? false)
(process-pending shapes thumbnails full (process-pending shapes thumbnails full font-pending-ids
(fn [] (fn []
(if render-callback (if render-callback
(render-callback) (render-callback)

View File

@ -119,6 +119,16 @@
(aget shape-id-buffer 2) (aget shape-id-buffer 2)
(aget shape-id-buffer 3))))) (aget shape-id-buffer 3)))))
(defn force-update-text-layout
[id]
(when wasm/context-initialized?
(let [shape-id-buffer (uuid/get-u32 id)]
(h/call wasm/internal-module "_force_update_shape_text_layout_for"
(aget shape-id-buffer 0)
(aget shape-id-buffer 1)
(aget shape-id-buffer 2)
(aget shape-id-buffer 3)))))
;; IMPORTANT: Only TTF fonts can be stored. ;; IMPORTANT: Only TTF fonts can be stored.
(defn- store-font-buffer (defn- store-font-buffer
[font-data font-array-buffer emoji? fallback?] [font-data font-array-buffer emoji? fallback?]

View File

@ -250,13 +250,15 @@
(api/set-shape-svg-raw-content (api/get-static-markup shape)) (api/set-shape-svg-raw-content (api/get-static-markup shape))
(cfh/text-shape? shape) (cfh/text-shape? shape)
(let [pending-thumbnails (into [] (concat (api/set-shape-text-content id v))) (let [text-content-pending (api/set-shape-text-content id v)
pending-full (into [] (concat (api/set-shape-text-images id v)))] pending-thumbnails (vec text-content-pending)
pending-full (vec (api/set-shape-text-images id v))
font-pending-ids (when (some :callback text-content-pending) [id])]
;; FIXME: this is a hack to process the pending tasks ;; FIXME: this is a hack to process the pending tasks
;; asynchronously we should probably modify set-wasm-attr! ;; asynchronously we should probably modify set-wasm-attr!
;; to return a list of callbacks to be executed in a ;; to return a list of callbacks to be executed in a
;; second pass. ;; second pass.
(api/process-pending [shape] pending-thumbnails pending-full api/noop-fn) (api/process-pending [shape] pending-thumbnails pending-full font-pending-ids api/noop-fn)
nil)) nil))
:grow-type :grow-type

View File

@ -51,7 +51,7 @@
(let [shapes [(make-shape :text) (make-shape :text) (make-shape :rect)] (let [shapes [(make-shape :text) (make-shape :text) (make-shape :rect)]
visited-ids (atom []) visited-ids (atom [])
mock-set (fn [s] (swap! visited-ids conj (:id s)) {:thumbnails [] :full []}) mock-set (fn [s] (swap! visited-ids conj (:id s)) {:thumbnails [] :full []})
mock-pend (fn [_sh _t _f _cb] nil)] mock-pend (fn [_sh _t _f _fp _cb] nil)]
(with-mocks* mock-set mock-pend #(wasm.api/process-objects shapes)) (with-mocks* mock-set mock-pend #(wasm.api/process-objects shapes))
@ -66,7 +66,7 @@
(let [shapes [(make-shape :text) (make-shape :text)] (let [shapes [(make-shape :text) (make-shape :text)]
captured (atom nil) captured (atom nil)
mock-set (fn [_s] {:thumbnails [] :full []}) mock-set (fn [_s] {:thumbnails [] :full []})
mock-pend (fn [sh t f cb] (reset! captured {:shapes sh :thumbnails t :full f :cb cb}))] mock-pend (fn [sh t f _fp cb] (reset! captured {:shapes sh :thumbnails t :full f :cb cb}))]
(with-mocks* mock-set mock-pend #(wasm.api/process-objects shapes)) (with-mocks* mock-set mock-pend #(wasm.api/process-objects shapes))
@ -99,7 +99,7 @@
{:thumbnails [] :full []})) {:thumbnails [] :full []}))
mock-pend mock-pend
(fn [sh t f _cb] (reset! captured {:shapes sh :thumbnails t :full f}))] (fn [sh t f _fp _cb] (reset! captured {:shapes sh :thumbnails t :full f}))]
(with-mocks* mock-set mock-pend #(wasm.api/process-objects shapes)) (with-mocks* mock-set mock-pend #(wasm.api/process-objects shapes))

View File

@ -410,6 +410,11 @@ pub(crate) struct RenderState {
/// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during /// GPU crops from `Backbuffer` or tile atlas keyed by shape id. Filled on full-frame completion; during
/// drag, entries for the moved top-level selection are ensured here /// drag, entries for the moved top-level selection are ensured here
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>, pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
/// Whether we've already forced a GPU flush+submit before a tile-atlas
/// snapshot this render. The first snapshot of a pass can otherwise capture
/// a tile before its text glyph uploads complete (blank first/center tile).
/// One explicit flush warms the submit path for the rest of the pass.
pub tile_atlas_flushed: bool,
} }
pub struct InteractiveDragCrop { pub struct InteractiveDragCrop {
@ -592,6 +597,7 @@ impl RenderState {
interactive_target_seeded: false, interactive_target_seeded: false,
preserve_target_during_render: false, preserve_target_during_render: false,
backbuffer_crop_cache: HashMap::default(), backbuffer_crop_cache: HashMap::default(),
tile_atlas_flushed: false,
}) })
} }
@ -1020,6 +1026,12 @@ impl RenderState {
.as_ref() .as_ref()
.ok_or(Error::CriticalError("Current tile not found".to_string()))?; .ok_or(Error::CriticalError("Current tile not found".to_string()))?;
// Force pending GPU work (text glyph-atlas uploads)
if !self.tile_atlas_flushed {
crate::get_gpu_state().context.flush_and_submit();
self.tile_atlas_flushed = true;
}
self.surfaces.draw_current_tile_into_tile_atlas( self.surfaces.draw_current_tile_into_tile_atlas(
&self.tile_viewbox, &self.tile_viewbox,
&current_tile, &current_tile,
@ -2090,6 +2102,7 @@ impl RenderState {
self.surfaces.atlas.set_doc_bounds(doc_bounds); self.surfaces.atlas.set_doc_bounds(doc_bounds);
self.cache_cleared_this_render = false; self.cache_cleared_this_render = false;
self.tile_atlas_flushed = false;
let preserve_target = self.preserve_target_during_render; let preserve_target = self.preserve_target_during_render;
self.preserve_target_during_render = false; self.preserve_target_during_render = false;

View File

@ -888,6 +888,11 @@ impl TextContent {
.copy_finite_size(result.2, default_width, default_height); .copy_finite_size(result.2, default_width, default_height);
} }
pub fn force_next_layout_update(&mut self) {
self.layout_width = None;
self.layout.cached_extrect.set(None);
}
pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize {
if !self.layout.needs_update() if !self.layout.needs_update()
&& self.layout_version == self.content_version && self.layout_version == self.content_version

View File

@ -370,8 +370,11 @@ pub extern "C" fn intersect_position_in_shape(
false false
} }
fn update_text_layout(shape: &mut Shape) { fn update_text_layout(shape: &mut Shape, force: bool) {
if let Type::Text(text_content) = &mut shape.shape_type { if let Type::Text(text_content) = &mut shape.shape_type {
if force {
text_content.force_next_layout_update();
}
text_content.update_layout(shape.selrect); text_content.update_layout(shape.selrect);
shape.invalidate_extrect(); shape.invalidate_extrect();
} }
@ -380,7 +383,7 @@ fn update_text_layout(shape: &mut Shape) {
#[no_mangle] #[no_mangle]
pub extern "C" fn update_shape_text_layout() { pub extern "C" fn update_shape_text_layout() {
with_current_shape_mut!(state, |shape: &mut Shape| { with_current_shape_mut!(state, |shape: &mut Shape| {
update_text_layout(shape); update_text_layout(shape, false);
}); });
} }
@ -389,7 +392,18 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
with_state!(state, { with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d); let shape_id = uuid_from_u32_quartet(a, b, c, d);
if let Some(shape) = state.shapes.get_mut(&shape_id) { if let Some(shape) = state.shapes.get_mut(&shape_id) {
update_text_layout(shape); update_text_layout(shape, false);
}
state.touch_shape(shape_id);
});
}
#[no_mangle]
pub extern "C" fn force_update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) {
with_state!(state, {
let shape_id = uuid_from_u32_quartet(a, b, c, d);
if let Some(shape) = state.shapes.get_mut(&shape_id) {
update_text_layout(shape, true);
} }
state.touch_shape(shape_id); state.touch_shape(shape_id);
}); });