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

View File

@ -119,6 +119,16 @@
(aget shape-id-buffer 2)
(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.
(defn- store-font-buffer
[font-data font-array-buffer emoji? fallback?]

View File

@ -250,13 +250,15 @@
(api/set-shape-svg-raw-content (api/get-static-markup shape))
(cfh/text-shape? shape)
(let [pending-thumbnails (into [] (concat (api/set-shape-text-content id v)))
pending-full (into [] (concat (api/set-shape-text-images id v)))]
(let [text-content-pending (api/set-shape-text-content 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
;; asynchronously we should probably modify set-wasm-attr!
;; to return a list of callbacks to be executed in a
;; 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))
:grow-type

View File

@ -51,7 +51,7 @@
(let [shapes [(make-shape :text) (make-shape :text) (make-shape :rect)]
visited-ids (atom [])
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))
@ -66,7 +66,7 @@
(let [shapes [(make-shape :text) (make-shape :text)]
captured (atom nil)
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))
@ -99,7 +99,7 @@
{:thumbnails [] :full []}))
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))

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
/// drag, entries for the moved top-level selection are ensured here
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 {
@ -592,6 +597,7 @@ impl RenderState {
interactive_target_seeded: false,
preserve_target_during_render: false,
backbuffer_crop_cache: HashMap::default(),
tile_atlas_flushed: false,
})
}
@ -1020,6 +1026,12 @@ impl RenderState {
.as_ref()
.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.tile_viewbox,
&current_tile,
@ -2090,6 +2102,7 @@ impl RenderState {
self.surfaces.atlas.set_doc_bounds(doc_bounds);
self.cache_cleared_this_render = false;
self.tile_atlas_flushed = false;
let preserve_target = self.preserve_target_during_render;
self.preserve_target_during_render = false;

View File

@ -888,6 +888,11 @@ impl TextContent {
.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 {
if !self.layout.needs_update()
&& self.layout_version == self.content_version

View File

@ -370,8 +370,11 @@ pub extern "C" fn intersect_position_in_shape(
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 force {
text_content.force_next_layout_update();
}
text_content.update_layout(shape.selrect);
shape.invalidate_extrect();
}
@ -380,7 +383,7 @@ fn update_text_layout(shape: &mut Shape) {
#[no_mangle]
pub extern "C" fn update_shape_text_layout() {
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, {
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);
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);
});