From 23224b6e9f73f8c721e200a1546f1607f0a53488 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:55:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?perf(ml):=20P2-2=20spatial=20index=20?= =?UTF-8?q?=E2=80=94=20subtree=20bounds=20+=20interval=20arrays?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lui_spatial library: a snapshot index over resolved node rects that both the paint pass's viewport cull and hit-testing prune with. Per node it records the shown subtree's bound [b], its clip-escaping part [eb] (promoted popups), and a zero-area flag [z]; parents with >= 32 children get a bound-top-sorted array with prefix-max bottoms so a query touches O(log n + candidates) siblings instead of every child. The env is caller-supplied so the module stays free of Lui_paint dependencies. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_spatial/dune | 5 + platform/native/lui_spatial/lui_spatial.ml | 287 +++++++++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 platform/native/lui_spatial/dune create mode 100644 platform/native/lui_spatial/lui_spatial.ml diff --git a/platform/native/lui_spatial/dune b/platform/native/lui_spatial/dune new file mode 100644 index 00000000..152f5768 --- /dev/null +++ b/platform/native/lui_spatial/dune @@ -0,0 +1,5 @@ +(library + (name lui_spatial) + (public_name lui.spatial) + (modules lui_spatial) + (libraries lui_store lui_scene)) diff --git a/platform/native/lui_spatial/lui_spatial.ml b/platform/native/lui_spatial/lui_spatial.ml new file mode 100644 index 00000000..1573bba9 --- /dev/null +++ b/platform/native/lui_spatial/lui_spatial.ml @@ -0,0 +1,287 @@ +(* Spatial index over resolved node rects (P2-2): per-node subtree + bounds plus per-parent interval-ordered child arrays, shared by + hit-testing and the paint pass's viewport cull. + + The index is a pure snapshot: [build] folds the store's shown tree + over a rect source (the hit mirror's resolved, scroll-shifted and + snapped rects — the same geometry [Lui_paint.walk] produces), and + the walk consumes it to skip subtrees that cannot contribute to the + query. Point queries (hit-testing) answer "which subtree could + contain this point"; region queries (paint) answer "which subtree + could intersect the viewport". Both preserve the walk's exact + semantics — z-ordered visit, promoted popups escaping ancestor + clips, display:contents pass-through, hidden subtrees skipped — so + the index records three facts per node: + + - [b]: union of the shown subtree's non-empty resolved rects. + Every clipped subtree area is a subrect of [b] ∩ clip, so a + point outside [b] (or a region disjoint from it) prunes safely. + - [eb]: the clip-escaping part of [b] — bounds of promoted popups, + which paint and hit-test above and outside their ancestors' + clip contexts. A point inside [eb] keeps the subtree alive even + when the current clip excludes it. + - [z]: the subtree contains a zero-area node. Empty rects still + run the emission path today (degenerate ops, split-pane clip + brackets), so a region query must keep such subtrees to stay + op-identical; point queries can ignore them — an empty rect + contains nothing. + + Wide parents (>= [kid_threshold] children) additionally get an + interval index: children sorted by bound top with a prefix-max + bottom table, so a query touches O(log n + candidates) siblings + instead of every child. *) + +open Lui_scene + +(** What a consumer is looking for. *) +type query = + | Point of float * float (** hit-testing: containment semantics *) + | Region of rect (** paint culling: intersection semantics *) + +(** Per-node subtree facts; [b] and [eb] may be empty rects. *) +type info = { b : rect; eb : rect; z : bool } + +(* One child inside a wide parent's interval index. [k_esc] mirrors + the walk's promotion rule ([popup_kind && not unboxed]) so queries + decide clip escape without fetching the node; [k_rank] is the + child's position in paint order so filtered results revisit + siblings exactly as the unindexed walk would. *) +type kid = { k_id : int; k_rank : int; k_esc : bool; ki : info } + +(* [ka]: children whose subtrees carry no zero-area node, sorted by + bound top — the interval-scanned array. [kmax]: prefix max over + [ka]'s bound bottoms — the scan stops once no earlier sibling can + reach the query band. [kz]: zero-subtree children, in paint order. + Region queries keep every [kz] child unconditionally (zero-area + nodes still run the emission path — clip brackets, degenerate + fills); point queries check them linearly since a zero-descendant + says nothing about the child's own bound. The split is disjoint: + a z-child in [ka] could be lost to the band cutoff a region query + must not apply to it. *) +type kids_arr = { ka : kid array; kmax : float array; kz : kid array } + +type t = { + info : (int, info) Hashtbl.t; + kids : (int, kids_arr) Hashtbl.t; + roots : int list; + (* the forest's roots, captured at build so the walk can start + without a fresh [Lui_store.root_ids] scan — the index is a + snapshot, so its roots are exactly the ones the caller should + visit. *) +} + +let empty = + { info = Hashtbl.create 8; kids = Hashtbl.create 2; roots = [] } + +let roots t = t.roots + +(* Above this child count a parent gets the sorted interval arrays; + below it the walk falls back to a per-child [keep] lookup. *) +let kid_threshold = 32 + +(** The store/paint predicates [build] needs, supplied by the caller + so this module stays free of Lui_paint dependencies: [shown] is + the walk's visibility gate, [promotes] is its clip-escape rule + (popup kind and not display:contents), [ordered] yields children + in paint order. *) +type env = { + shown : Lui_store.t -> int -> bool; + promotes : Lui_store.t -> int -> bool; + ordered : Lui_store.t -> int -> Lui_store.node list; +} + +let info t id = Hashtbl.find_opt t.info id + +(* Union accumulators: bounds fold as raw (x0,y0,x1,y1) edges so an + untouched accumulator materializes to an empty rect. *) +type acc = { + mutable x0 : float; + mutable y0 : float; + mutable x1 : float; + mutable y1 : float; +} + +let acc_empty () = + { x0 = Float.max_float; y0 = Float.max_float; + x1 = Float.min_float; y1 = Float.min_float } + +let acc_nonvoid a = a.x1 > a.x0 && a.y1 > a.y0 + +let acc_union a b = + if acc_nonvoid b then begin + if b.x0 < a.x0 then a.x0 <- b.x0; + if b.y0 < a.y0 then a.y0 <- b.y0; + if b.x1 > a.x1 then a.x1 <- b.x1; + if b.y1 > a.y1 then a.y1 <- b.y1 + end + +let acc_add a r = + if not (rect_empty r) then begin + if r.x < a.x0 then a.x0 <- r.x; + if r.y < a.y0 then a.y0 <- r.y; + if r.x +. r.w > a.x1 then a.x1 <- r.x +. r.w; + if r.y +. r.h > a.y1 then a.y1 <- r.y +. r.h + end + +let acc_rect a = + if acc_nonvoid a then rect a.x0 a.y0 (a.x1 -. a.x0) (a.y1 -. a.y0) + else rect 0. 0. 0. 0. + +(* Post-order fold over shown nodes: [b] unions every non-empty + resolved rect in the subtree, [eb] unions the bounds of children + that promote (their whole subtree escapes this node's clip + context; non-promoting children contribute only their own + escapees upward), [z] flags zero-area rects a region query must + keep visiting. *) +let build env store ~rect_of = + let roots = Lui_store.root_ids store in + let idx = + { info = Hashtbl.create 256; kids = Hashtbl.create 16; roots } + in + let rec visit id : acc * acc * bool = + if not (env.shown store id) then (acc_empty (), acc_empty (), false) + else begin + let b = acc_empty () and eb = acc_empty () in + let z = ref false in + List.iter + (fun cid -> + let cb, ceb, cz = visit cid in + acc_union b cb; + (* A promoting child's whole bound escapes the clip context + this node's children share; a non-promoting child passes + only its own escapees upward. *) + acc_union eb (if env.promotes store cid then cb else ceb); + if cz then z := true) + (Lui_store.child_ids store id); + let r = rect_of id in + acc_add b r; + let z = !z || rect_empty r in + Hashtbl.replace idx.info id { b = acc_rect b; eb = acc_rect eb; z }; + if Lui_store.child_count store id >= kid_threshold then + build_kids id; + (b, eb, z) + end + and build_kids id = + (* Paint-order positions are ranks; [ka] sorts by bound top for + the interval scan. Hidden children never entered [info], so + they drop out of both arrays. *) + let arr = + env.ordered store id + |> List.mapi (fun rank n -> (rank, Lui_store.node_id n)) + |> List.filter_map (fun (rank, cid) -> + match Hashtbl.find_opt idx.info cid with + | None -> None + | Some ki -> + Some + { k_id = cid; k_rank = rank; + k_esc = env.promotes store cid; ki }) + |> Array.of_list + in + let ka = + let a = + Array.of_list + (List.filter (fun k -> not k.ki.z) (Array.to_list arr)) + in + Array.sort (fun x c -> Float.compare x.ki.b.y c.ki.b.y) a; + a + in + let kmax = Array.make (Array.length ka) 0. in + Array.iteri + (fun i k -> + let bot = k.ki.b.y +. k.ki.b.h in + kmax.(i) <- (if i = 0 then bot else Float.max kmax.(i - 1) bot)) + ka; + let kz = Array.of_list + (List.filter (fun k -> k.ki.z) (Array.to_list arr)) in + Hashtbl.replace idx.kids id { ka; kmax; kz } + in + List.iter (fun id -> ignore (visit id)) roots; + idx + +(* The keep-test shared by the per-child fallback and the interval + arrays. [i] is the child's subtree facts; [esc] whether the child + itself promotes (its frame then runs clip-free); [clip] the clip + context its subtree is visited under. *) +let ok i ~esc ~clip q = + match q with + | Region r -> i.z || not (rect_empty (rect_intersect i.b r)) + | Point (x, y) -> + (esc && rect_contains i.b x y) + || rect_contains i.eb x y + || (rect_contains i.b x y + && + match clip with + | Some c -> rect_contains c x y + | None -> true) + +(* May the subtree of node [id] contribute to [q] under clip context + [clip]? Absent info means a hidden subtree — the walk skips it + wholesale either way. *) +let keep t ~clip ~esc q id = + match Hashtbl.find_opt t.info id with + | None -> false + | Some i -> ok i ~esc ~clip q + +(* Count of [ka] entries whose bound top precedes [ylimit] ([<] for + regions — a touching band is an empty intersection — [<=] for + points). Entries past it start below the query band. *) +let band_end (ka : kid array) ~inclusive ylimit = + let n = Array.length ka in + let rec go lo hi = + if lo >= hi then lo + else + let mid = (lo + hi) / 2 in + if ka.(mid).ki.b.y < ylimit + || (inclusive && ka.(mid).ki.b.y = ylimit) + then go (mid + 1) hi + else go lo mid + in + go 0 n + +(* Children of [pid] that can contribute to [q], in paint order. + [None] when the parent has no interval index — the caller falls + back to per-child [keep] tests. *) +let children t ~clip q pid : int list option = + match Hashtbl.find_opt t.kids pid with + | None -> None + | Some karr -> + let hits = + match q with + | Point (_, y) -> + (* Candidates need b.y <= y < b.bottom: scan the prefix of + tops <= y while the prefix-max bottom still reaches y. *) + let hi = band_end karr.ka ~inclusive:true y in + let acc = ref [] in + let rec back i = + if i >= 0 && karr.kmax.(i) > y then begin + let k = karr.ka.(i) in + if ok k.ki ~esc:k.k_esc ~clip q then acc := k :: !acc; + back (i - 1) + end + in + back (hi - 1); + Array.fold_left + (fun acc k -> if ok k.ki ~esc:k.k_esc ~clip q then k :: acc + else acc) + !acc karr.kz + | Region r -> + let hi = band_end karr.ka ~inclusive:false (r.y +. r.h) in + let acc = ref [] in + let rec back i = + if i >= 0 && karr.kmax.(i) > r.y then begin + let k = karr.ka.(i) in + if ok k.ki ~esc:k.k_esc ~clip q then acc := k :: !acc; + back (i - 1) + end + in + back (hi - 1); + (* Zero-subtree children always stay reachable for emission; + merge them into the ranked result. *) + Array.fold_left (fun acc k -> k :: acc) !acc karr.kz + in + (* Re-establish paint order: ranks sort the candidates exactly as + ordered_children would list them. *) + let sorted = + List.sort (fun a c -> compare a.k_rank c.k_rank) hits + in + Some (List.map (fun k -> k.k_id) sorted) From d07c68b65c73cc23f5b28119921ea89346385f63 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:55:24 +0800 Subject: [PATCH 2/4] =?UTF-8?q?perf(ml):=20P2-2=20indexed=20walk=20?= =?UTF-8?q?=E2=80=94=20paint=20viewport=20cull=20via=20spatial=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lui_paint.walk takes an optional (index, query) pair: roots come from the index's snapshot (avoiding the O(n) root_ids fold per walk), wide parents enumerate candidates through the interval arrays, and small parents fall back to a per-child keep test that preserves the walk's exact clip/popup/z-order semantics. Lui_paint.paint gains ?spatial supplying a Region query over the viewport; without it the walk is byte-for-byte the old path. spatial_env publishes the paint-semantics env index builders share. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_paint/dune | 2 +- platform/native/lui_paint/lui_paint.ml | 96 ++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/platform/native/lui_paint/dune b/platform/native/lui_paint/dune index 7f26a13c..77ff89da 100644 --- a/platform/native/lui_paint/dune +++ b/platform/native/lui_paint/dune @@ -2,4 +2,4 @@ (name lui_paint) (public_name lui.paint) (modules lui_paint) - (libraries lui_store lui_scene)) + (libraries lui_store lui_scene lui_spatial)) diff --git a/platform/native/lui_paint/lui_paint.ml b/platform/native/lui_paint/lui_paint.ml index 6bc42bc2..0410cbf6 100644 --- a/platform/native/lui_paint/lui_paint.ml +++ b/platform/native/lui_paint/lui_paint.ml @@ -2346,6 +2346,16 @@ let node_shown store id = let unboxed store id = pstr store id "display" = Some "contents" +(* The store/paint predicates a spatial index build needs, wired to + this module's own rules so index bounds always mirror the walk's + visibility and clip-escape decisions. *) +let spatial_env : Lui_spatial.env = + { shown = node_shown; + promotes = + (fun store id -> + popup_kind (Lui_store.kind store id) && not (unboxed store id)); + ordered = ordered_children } + (* ---------- shared stacking walk ---------- *) (* One frame of the tree walk paint and hit-testing share: the node's @@ -2383,8 +2393,15 @@ type 'a branch = { pass, display:contents unboxed — carrying [clip] (the accumulated ancestor clip) and the caller's context down the tree. [enter] fires on every shown node, [leave] closes frames whose own chrome - emitted, and both receive the context the caller threaded down. *) -let walk ~scale ~viewport store ~layout ~enter ~leave ctx0 = + emitted, and both receive the context the caller threaded down. + + [?spatial] supplies a fresh [Lui_spatial] index plus the query the + walk serves ([Point] for hit-testing, [Region] for paint culling): + children whose subtree bound cannot contribute are never entered, + and wide parents enumerate candidates through the interval index + instead of scanning every child. Pruning is a strict filter on the + unindexed walk's visit set — same order, same clip contexts. *) +let walk ~scale ~viewport store ~layout ~enter ~leave ?spatial ctx0 = let deferred = Queue.create () in let rec go ctx clip parent_r (n : Lui_store.node) = let id = Lui_store.node_id n and kind = Lui_store.node_kind n in @@ -2418,18 +2435,65 @@ let walk ~scale ~viewport store ~layout ~enter ~leave ctx0 = f_parent_r = (if contents then parent_r else r) } in let br = enter ctx f in - if br.b_down then + if br.b_down then begin let child_clip = if clips_own then Some clipped else clip in - List.iter (go br.b_ctx child_clip f.f_parent_r) - (ordered_children store id); + match spatial with + | None -> + List.iter (go br.b_ctx child_clip f.f_parent_r) + (ordered_children store id) + | Some (idx, q) -> ( + match Lui_spatial.children idx ~clip:child_clip q id with + | Some ids -> + (* Indexed parent: candidates arrive in paint order. *) + List.iter + (fun cid -> + match Lui_store.find_opt store cid with + | Some c -> go br.b_ctx child_clip f.f_parent_r c + | None -> ()) + ids + | None -> + List.iter + (fun c -> + let cid = Lui_store.node_id c in + if + Lui_spatial.keep idx ~clip:child_clip + ~esc: + (popup_kind (Lui_store.node_kind c) + && not (unboxed store cid)) + q cid + then go br.b_ctx child_clip f.f_parent_r c) + (ordered_children store id)) + end; if br.b_emit then leave br.b_ctx f in + let roots = + (* [root_ids] folds the whole store — with an index the snapshot + already knows the roots. *) + match spatial with + | None -> Lui_store.root_ids store + | Some (idx, _) -> Lui_spatial.roots idx + in List.iter - (fun id -> go ctx0 None viewport - (match Lui_store.find_opt store id with - | Some n -> n - | None -> invalid_arg "Lui_paint.walk: root id missing")) - (Lui_store.root_ids store); + (fun id -> + let keep = + match spatial with + | None -> true + | Some (idx, q) -> + (match Lui_store.find_opt store id with + | Some n -> + Lui_spatial.keep idx ~clip:None + ~esc: + (popup_kind (Lui_store.node_kind n) + && not (unboxed store id)) + q id + | None -> false) + in + if keep then + go ctx0 None viewport + (match Lui_store.find_opt store id with + | Some n -> n + | None -> invalid_arg "Lui_paint.walk: root id missing")) + roots; (* Promoted popups drain after the tree — nested popups enqueue during the drain and preserve preorder among popups. *) while not (Queue.is_empty deferred) do @@ -2561,8 +2625,11 @@ let paint_leave ctx store scene alpha f = emit scene (scrollbar_ops ctx store f.f_id f.f_rect alpha); if f.f_clips then emit1 scene Pop_clip -(* Full repaint: reset the scene and repaint the whole forest. *) -let paint hooks store scene ~width ~height ~scale ~clear = +(* Full repaint: reset the scene and repaint the whole forest. A + [?spatial] index fresh for the geometry about to resolve lets the + walk skip subtrees whose bounds miss the viewport instead of + visiting every mounted node — see {!Lui_spatial}. *) +let paint ?spatial hooks store scene ~width ~height ~scale ~clear = Lui_scene.reset scene ~width ~height ~scale ~clear; let ctx = { hooks; scale; @@ -2571,7 +2638,10 @@ let paint hooks store scene ~width ~height ~scale ~clear = in walk ~scale ~viewport:ctx.viewport store ~layout:hooks.layout ~enter:(paint_enter ctx store scene) - ~leave:(paint_leave ctx store scene) 1.; + ~leave:(paint_leave ctx store scene) + ?spatial: + (Option.map (fun i -> (i, Lui_spatial.Region ctx.viewport)) spatial) + 1.; (* Extension renderers may enqueue their own deferred work on ctx.deferred — drain it after the walk's own popup pass. *) while not (Queue.is_empty ctx.deferred) do From 4bf8d06536e94567a5d3900cd6c4a7eb7e057bbf Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:55:37 +0800 Subject: [PATCH 3/4] perf(ml): P2-2 wire the spatial index through host and window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lui_query.hit_path/hit take ?spatial and serve point queries through it — same walk, same path order. - Lui_host gains an invalidation counter (bumped by resize, set_hidden, set_ctx_anchor, invalidate_layout) plus a spatial provider thunk consulted inside repaint. - Lui_window.Assembly builds the index lazily over the populate-time hit mirror, stamps each mirror with a geometry key (store revision, host invalidations, scroll epoch, live context anchor, scale, viewport) and hands the paint pass an index only when the key proves the mirror still matches the frame being resolved; hit paths get the index whenever it matches the current mirror. Stale or absent keys fall back to the unindexed walk, so the index can never serve a wrong answer. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_host/dune | 2 +- platform/native/lui_host/lui_host.ml | 35 +++++++-- platform/native/lui_host/lui_host.mli | 13 ++++ platform/native/lui_input/dune | 2 +- platform/native/lui_input/lui_query.ml | 17 +++-- platform/native/lui_window/dune | 2 +- platform/native/lui_window/lui_window.ml | 92 +++++++++++++++++------ platform/native/lui_window/lui_window.mli | 20 +++-- 8 files changed, 144 insertions(+), 39 deletions(-) diff --git a/platform/native/lui_host/dune b/platform/native/lui_host/dune index 9d0cb9e9..7123d55c 100644 --- a/platform/native/lui_host/dune +++ b/platform/native/lui_host/dune @@ -2,4 +2,4 @@ (name lui_host) (public_name lui.host) (modules lui_host) - (libraries lui lui_store lui_scene lui_paint)) + (libraries lui lui_store lui_scene lui_paint lui_spatial)) diff --git a/platform/native/lui_host/lui_host.ml b/platform/native/lui_host/lui_host.ml index ec3f5c55..339d40b5 100644 --- a/platform/native/lui_host/lui_host.ml +++ b/platform/native/lui_host/lui_host.ml @@ -63,6 +63,16 @@ type t = { (* Pointer point an unanchored context-menu opens at — a thunk so the accessor reads the live position each repaint. *) mutable ctx_anchor : unit -> (float * float) option; + (* Spatial index provider for the paint pass's viewport cull — + returns [Some index] only when the caller can prove it matches + the geometry this repaint resolves (see [Lui_spatial]); [None] + is always safe: the walk just visits every node. *) + mutable spatial : unit -> Lui_spatial.t option; + (* Bumped by every host-side poke that can change layout output + without a store revision — [invalidate_layout], [resize], + [set_hidden], [set_ctx_anchor]. Epoch keys mixing it in stay + honest across external measure/visibility changes. *) + mutable inv_count : int; } let no_measure _id _text _offered_w _offered_h = None @@ -88,7 +98,8 @@ let create ~hooks ?layout ?(measure = no_measure) ~renderer ~width ~height ~scal scene = Lui_scene.create ~mask_atlas ~color_atlas; hooks; layout; measure; renderer; width; height; scale; dirty = false; hidden = (fun _ -> false); - ctx_anchor = (fun () -> None) } + ctx_anchor = (fun () -> None); spatial = (fun () -> None); + inv_count = 0 } let store h = h.store let scene h = h.scene @@ -96,26 +107,40 @@ let wants_repaint h = h.dirty let set_hidden h pred = h.hidden <- (match pred with Some p -> p | None -> fun _ -> false); + h.inv_count <- h.inv_count + 1; h.dirty <- true let set_ctx_anchor h accessor = h.ctx_anchor <- - (match accessor with Some f -> f | None -> fun () -> None) + (match accessor with Some f -> f | None -> fun () -> None); + h.inv_count <- h.inv_count + 1 (* The layout engine memoizes on (store revision, frame, input identities); a measure/hidden closure whose answers changed under a stable identity — e.g. a font stack that finished loading — needs this poke to be seen. *) let invalidate_layout h = + h.inv_count <- h.inv_count + 1; match h.layout with Some l -> l.linvalidate () | None -> () +let invalidation_count h = h.inv_count + +(* Install the spatial-index provider the paint pass consults; the + thunk runs inside [repaint] after layout sync, so it sees the + frame's final geometry inputs. *) +let set_spatial h f = h.spatial <- f + let apply_batch h (batch : Lui_protocol.patch_batch) = Lui_store.apply_batch h.store batch; h.dirty <- true; true let resize h ~width ~height ~scale = - h.width <- width; h.height <- height; h.scale <- scale; h.dirty <- true + h.width <- width; + h.height <- height; + h.scale <- scale; + h.inv_count <- h.inv_count + 1; + h.dirty <- true let prof_t0 = Sys.time () let prof msg = @@ -152,8 +177,8 @@ let repaint h = | Some c -> c | None -> { r = 0; g = 0; b = 0; a = 0 } in - Lui_paint.paint hooks h.store h.scene ~width:h.width ~height:h.height - ~scale:h.scale ~clear; + Lui_paint.paint ?spatial:(h.spatial ()) hooks h.store h.scene + ~width:h.width ~height:h.height ~scale:h.scale ~clear; prof "paint done"; h.dirty <- false; let bytes = h.renderer.render h.scene in diff --git a/platform/native/lui_host/lui_host.mli b/platform/native/lui_host/lui_host.mli index e33204c3..42758ab2 100644 --- a/platform/native/lui_host/lui_host.mli +++ b/platform/native/lui_host/lui_host.mli @@ -98,6 +98,19 @@ val invalidate_layout : t -> unit keys on, and sync always runs between batches, so the memo can never observe a half-applied tree. *) +val invalidation_count : t -> int +(** Monotonic counter bumped by every host-side input that can move + layout output without a store revision: {!invalidate_layout}, + {!resize}, {!set_hidden}, {!set_ctx_anchor}. Epoch keys that must + stay honest about resolved geometry mix it in. *) + +val set_spatial : t -> (unit -> Lui_spatial.t option) -> unit +(** Install the spatial-index provider the paint pass consults for + viewport culling. The thunk runs inside {!repaint} after layout + sync; it must return [Some index] only when the index provably + matches the geometry this frame resolves, [None] otherwise — + [None] is always safe, the paint pass just walks every node. *) + val apply_batch : t -> Lui_protocol.patch_batch -> bool val resize : t -> width:int -> height:int -> scale:float -> unit diff --git a/platform/native/lui_input/dune b/platform/native/lui_input/dune index 113e3367..64b75c08 100644 --- a/platform/native/lui_input/dune +++ b/platform/native/lui_input/dune @@ -3,5 +3,5 @@ (public_name lui.input) (modules lui_input lui_query lui_pointer lui_gesture lui_focus lui_selection lui_scroll lui_drag lui_overlay lui_text_input) - (libraries lui lui_store lui_scene lui_ime lui_paint) + (libraries lui lui_store lui_scene lui_ime lui_paint lui_spatial) (wrapped false)) diff --git a/platform/native/lui_input/lui_query.ml b/platform/native/lui_input/lui_query.ml index d3ffc8bd..4abd9aa9 100644 --- a/platform/native/lui_input/lui_query.ml +++ b/platform/native/lui_input/lui_query.ml @@ -139,8 +139,13 @@ let hit_flags_of store id = display:contents pass-through, and the clip context computed with [Lui_paint.clips]'s exact rule. [rects] carries the driver's resolved boxes — fed back as placement overrides so the walk - shares the stacking/clip result without re-resolving geometry. *) -let hit_path store rects ~x ~y = + shares the stacking/clip result without re-resolving geometry. + [?spatial] is the subtree-bound index built over that same mirror + ([Lui_spatial.build] with [~rect_of:(Rects.get rects)]): with it, + the walk descends only toward subtrees that could contain the + point instead of visiting every node — O(log n + path) instead of + O(n) per pointer event. *) +let hit_path ?spatial store rects ~x ~y = let path = ref [] in let enter on (f : Lui_paint.frame) = let fl = hit_flags_of store f.f_id in @@ -162,13 +167,15 @@ let hit_path store rects ~x ~y = ~layout:(fun id -> let r = Rects.get rects id in { Lui_paint.p_rect = r; p_override = Some r }) - ~enter ~leave:(fun _ _ -> ()) true; + ~enter ~leave:(fun _ _ -> ()) + ?spatial:(Option.map (fun i -> (i, Lui_spatial.Point (x, y))) spatial) + true; (* The head is the node visited last in paint order: visually topmost. *) !path -let hit store rects ~x ~y = - match hit_path store rects ~x ~y with +let hit ?spatial store rects ~x ~y = + match hit_path ?spatial store rects ~x ~y with | id :: _ -> Some id | [] -> None diff --git a/platform/native/lui_window/dune b/platform/native/lui_window/dune index 0a778c52..60bdbb92 100644 --- a/platform/native/lui_window/dune +++ b/platform/native/lui_window/dune @@ -2,7 +2,7 @@ (name lui_window) (public_name lui.window) (modules lui_window) - (libraries lui lui_store lui_scene lui_paint lui_host lui_ime lui_input)) + (libraries lui lui_store lui_scene lui_paint lui_host lui_ime lui_input lui_spatial)) ; The demo app lives in its own library (not the window library) so ; tests can drive its model/action/event paths directly. diff --git a/platform/native/lui_window/lui_window.ml b/platform/native/lui_window/lui_window.ml index 2b4f2d0a..a8730d1b 100644 --- a/platform/native/lui_window/lui_window.ml +++ b/platform/native/lui_window/lui_window.ml @@ -753,7 +753,7 @@ module Ui = struct let store_changed t store = sync t store - let handle t store rects input = + let handle ?spatial t store rects input = let acts = ref [] in let emit_all l = acts := List.rev_append l !acts in let run m_acts = emit_all (perform_all t store m_acts) in @@ -764,7 +764,7 @@ module Ui = struct let dx, dy = x *. t.scale, y *. t.scale in if Lui_drag.active t.drag then begin (* live drop-target tracking while a drag is in flight *) - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let d, m = Lui_drag.track store t.drag ~path in t.drag <- d; run m @@ -799,7 +799,7 @@ module Ui = struct run m | None -> t.dirty <- true) end else begin - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let p, m = Lui_pointer.move store t.pointer ~path ~x ~y in t.pointer <- p; run m @@ -810,7 +810,7 @@ module Ui = struct (match btn with | Input.Left -> let dx, dy = x *. t.scale, y *. t.scale in - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in t.drag <- Lui_drag.note_down t.drag ~x ~y; t.gesture <- Lui_gesture.reset_typeahead t.gesture; (* a press outside the open menu closes it host-side *) @@ -880,7 +880,7 @@ module Ui = struct run m) | Input.Right -> let dx, dy = x *. t.scale, y *. t.scale in - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let p, m = Lui_pointer.down store t.pointer ~path ~x ~y ~button:Lui_input.Right ~clicks ~mods:(conv_mods mods) @@ -909,7 +909,7 @@ module Ui = struct t.pointer <- p; emit_all (perform_all t store up_acts); let dx, dy = x *. t.scale, y *. t.scale in - let path = hit_path store rects ~x:dx ~y:dy in + let path = hit_path ?spatial store rects ~x:dx ~y:dy in let release = match Lui_query.press_target store path with | Some id -> id @@ -1116,7 +1116,7 @@ module Ui = struct | Input.Wheel (dx, dy) -> let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let s', m = @@ -1128,7 +1128,7 @@ module Ui = struct | Input.Wheel_mods (dx, dy, mods) -> let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let s', m = @@ -1144,7 +1144,7 @@ module Ui = struct them from internal drags *) let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let d, m = @@ -1155,7 +1155,7 @@ module Ui = struct | Input.Drop_text s -> let sx, sy = Lui_pointer.position t.pointer in let path = - hit_path store rects ~x:(sx *. t.scale) + hit_path ?spatial store rects ~x:(sx *. t.scale) ~y:(sy *. t.scale) in let d, m = @@ -1707,6 +1707,9 @@ module Assembly = struct caret_rect : unit -> rect; (** Focused field's caret rect, device px — candidate-window anchor while composing. *) + spatial_hit : unit -> Lui_spatial.t option; + (** Fresh subtree-bound index for {!Ui.handle} — [None] until + the first {!populate} has produced rects. *) } let host t = t.host @@ -1717,7 +1720,8 @@ module Assembly = struct let populate t = t.populate () let caret_rect t = t.caret_rect () let store t = Lui_host.store t.host - let handle t input = Ui.handle t.ui (store t) t.rects input + let handle t input = + Ui.handle ?spatial:(t.spatial_hit ()) t.ui (store t) t.rects input let store_changed t = Ui.store_changed t.ui (store t) (* One frame's paint path: the engine re-syncs inside @@ -1915,14 +1919,56 @@ module Assembly = struct the live pointer position unconditionally would defeat the layout memo on every pointer-driven repaint, so the accessor returns [None] the rest of the time. *) - Lui_host.set_ctx_anchor host - (Some - (fun () -> - match Ui.open_menu ui with - | 0 -> None - | id when Lui_store.kind store id = "context-menu" -> - Some (Ui.pointer ui) - | _ -> None)); + let ctx_anchor () = + match Ui.open_menu ui with + | 0 -> None + | id when Lui_store.kind store id = "context-menu" -> + Some (Ui.pointer ui) + | _ -> None + in + Lui_host.set_ctx_anchor host (Some ctx_anchor); + (* The spatial index over the hit mirror — see [Lui_spatial]. It + is rebuilt lazily whenever the rects or store shape it was + derived from moved on; [sp_key] records the geometry inputs + [populate] built the mirror under so the paint pass can prove + an index is fresh for the frame it is about to resolve. The + key must cover everything that moves resolved rects: store + contents, host-side layout invalidations (font loads, menu + visibility), scroll offsets, the live context anchor, scale + and viewport size. *) + let geom_key () = + let scn = Lui_host.scene host in + ( Lui_store.revision store, Lui_host.invalidation_count host, + Ui.scroll_epoch ui, ctx_anchor (), !scale_cell, + scn.Lui_scene.width, scn.Lui_scene.height ) + in + let sp_key = ref None in + let sp_idx = ref None in + let spatial_index () = + let rev = Lui_store.revision store in + match !sp_idx with + | Some (k, r, idx) when k = !sp_key && r = rev -> idx + | _ -> + let idx = + Lui_spatial.build Lui_paint.spatial_env store + ~rect_of:(Rects.get rects) + in + sp_idx := Some (!sp_key, rev, idx); + idx + in + (* Paint may use the index only when the mirror was built under + the same geometry inputs this frame resolves with — otherwise + the walk falls back to visiting everything. *) + Lui_host.set_spatial host + (fun () -> + match !sp_key with + | Some k when k = geom_key () -> Some (spatial_index ()) + | _ -> None); + let spatial_for_hit () = + match !sp_key with + | Some _ -> Some (spatial_index ()) + | None -> None + in (* Populate the hit-test mirror from the layout engine: each node's shifted rect clipped to its scrollable ancestors' viewports (so scrolled-off content can't be hit), then each @@ -1988,7 +2034,10 @@ module Assembly = struct (Lui_store.preorder store); (* Rects were just rebuilt — visible-range memos key on the epoch. *) - Ui.notify_layout ui + Ui.notify_layout ui; + (* Stamp the geometry inputs this mirror was built under — the + spatial index and the paint cull key off it. *) + sp_key := Some (geom_key ()) in (* Caret rect of the focused field, device px: the node's layout rect shifted to the caret's real position inside the laid-out @@ -2018,5 +2067,6 @@ module Assembly = struct horizontally, visual (soft-wrapped) lines vertically. *) Ui.set_caret_ops ui (fun _store id s ~lw ~lh -> caret_ops id s ~lw ~lh); Ui.set_content_rect ui engine_rect; - { host; ui; rects; scale_cell; engine_rect; populate; caret_rect } + { host; ui; rects; scale_cell; engine_rect; populate; caret_rect; + spatial_hit = spatial_for_hit } end diff --git a/platform/native/lui_window/lui_window.mli b/platform/native/lui_window/lui_window.mli index 5e6a4143..d5067d45 100644 --- a/platform/native/lui_window/lui_window.mli +++ b/platform/native/lui_window/lui_window.mli @@ -187,11 +187,18 @@ val event_supported : (** Deepest-first id path from the root to the topmost node whose layout rect contains [(x, y)] (device pixels). Skips invisible and zero-area nodes. [pointer-enabled] only gates pointer-detail - events — plain clicks still reach Press-able nodes. *) + events — plain clicks still reach Press-able nodes. + + [?spatial] is a {!Lui_spatial.t} built over the same [rects] + mirror — the walk then visits only subtrees that could contain + the point. *) val hit_path : - Lui_store.t -> Rects.t -> x:float -> y:float -> int list + ?spatial:Lui_spatial.t -> Lui_store.t -> Rects.t -> x:float -> + y:float -> int list -val hit : Lui_store.t -> Rects.t -> x:float -> y:float -> int option +val hit : + ?spatial:Lui_spatial.t -> Lui_store.t -> Rects.t -> x:float -> + y:float -> int option (** Deterministic layout stub. Stacks children vertically inside their parent's padded content box (or horizontally for rows), @@ -361,8 +368,11 @@ module Ui : sig before each input too. *) val store_changed : t -> Lui_store.t -> action list - (** Map one input event to ordered actions. *) - val handle : t -> Lui_store.t -> Rects.t -> Input.t -> action list + (** Map one input event to ordered actions. [?spatial] accelerates + the rect hit-tests — see {!Lui_window.hit_path}. *) + val handle : + ?spatial:Lui_spatial.t -> t -> Lui_store.t -> Rects.t -> + Input.t -> action list (** [state_of] hook for the paint pipeline: folds hover/press/focus from this Ui plus selected/disabled props. *) From 7bc02d09c770d4456df9f9a1e931e88957f92bf0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Tue, 13 Oct 2026 07:55:39 +0800 Subject: [PATCH 4/4] perf(ml): P2-2 spatial tests + hit benchmark - lui_paint tests: indexed-vs-unindexed paint produces identical op streams (z-order, clip, promoted popup, contents, hidden, spacer), a 300-row column visits <= 12 nodes under a 100px viewport vs 302 unindexed, and zero-area children stay reachable. - lui_input tests: a point-grid sweep asserts hit_path indexed == unindexed on a tree covering clip escape, display:contents, hidden subtrees, spacers and z-overlap; plus promoted-popover and wide-list hits. - perf scenario hit-*k: hit_path p50 unindexed vs indexed, indexed static-frame paint, and index build cost. At 100k rows: hit_path 320ms -> ~0.002ms; indexed static paint 0.06ms vs ~120ms; index build ~250ms once per geometry epoch. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- platform/native/lui_input/test/dune | 2 +- .../native/lui_input/test/lui_input_test.ml | 98 +++++++++++- platform/native/lui_paint/test/dune | 2 +- .../native/lui_paint/test/lui_paint_test.ml | 145 +++++++++++++++++- platform/native/perf/dune | 3 +- platform/native/perf/scenarios.ml | 73 +++++++++ 6 files changed, 317 insertions(+), 6 deletions(-) diff --git a/platform/native/lui_input/test/dune b/platform/native/lui_input/test/dune index 2faae869..3ff42c09 100644 --- a/platform/native/lui_input/test/dune +++ b/platform/native/lui_input/test/dune @@ -1,4 +1,4 @@ (test (name lui_input_test) (modules lui_input_test) - (libraries lui_input lui lui_store lui_scene lui_ime alcotest)) + (libraries lui_input lui lui_store lui_scene lui_ime lui_paint lui_spatial alcotest)) diff --git a/platform/native/lui_input/test/lui_input_test.ml b/platform/native/lui_input/test/lui_input_test.ml index d118d4eb..9c62a0ec 100644 --- a/platform/native/lui_input/test/lui_input_test.ml +++ b/platform/native/lui_input/test/lui_input_test.ml @@ -417,6 +417,95 @@ let test_pointer_hover_press () = (function `Dispatch (PointerUp (2, _)) -> true | _ -> false) m2) +(* ---------- spatial index (P2-2) ---------- *) + +(* A tree exercising everything hit-testing must agree on: a scroll + clip with clipped-away children, a popover promoted out of that + clip, a wide list exercising the interval index, display:contents, + a hidden subtree, a spacer and z-reordered overlap. *) +let spatial_hit_store () = + store_of + ([ CreateNode (1, Root); CreateNode (2, Scroll); + CreateNode (3, Button); + CreateNode (4, Column); CreateNode (50, Popover); + CreateNode (7, ListContainer); + CreateNode (8, Column); CreateNode (9, Button); + CreateNode (10, Button); CreateNode (11, Spacer); + CreateNode (12, Button); CreateNode (13, Button); + SetProp (8, DisplayValue, StringValue "contents"); + SetProp (10, Visible, BoolValue false); + SetProp (12, ZIndex, IntValue 1); + SetProp (13, ZIndex, IntValue 2); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); InsertChild (2, 4, 1); + InsertChild (4, 50, 0); + InsertChild (1, 7, 1); + InsertChild (1, 8, 2); InsertChild (8, 9, 0); + InsertChild (1, 10, 3); InsertChild (1, 11, 4); + InsertChild (1, 12, 5); InsertChild (1, 13, 6) ] + @ List.concat_map + (fun i -> + [ CreateNode (100 + i, ListItem); + SetProp (100 + i, TextValue, StringValue "row"); + InsertChild (7, 100 + i, i) ]) + (List.init 40 Fun.id)) + +let spatial_hit_rects = + [ (1, rect 0. 0. 300. 200.); (2, rect 0. 0. 100. 80.); + (3, rect 0. 0. 100. 40.); (4, rect 0. 120. 100. 40.); + (50, rect 0. 120. 60. 20.); (7, rect 100. 0. 200. 200.); + (8, rect 100. 60. 100. 20.); (9, rect 100. 60. 100. 20.); + (10, rect 150. 100. 50. 50.); (11, rect 0. 90. 100. 10.); + (12, rect 150. 150. 50. 40.); (13, rect 150. 150. 50. 40.) ] + @ List.init 40 (fun i -> + (100 + i, rect 100. (float_of_int i *. 5.) 200. 5.)) + +let spatial_index store rects = + Lui_spatial.build Lui_paint.spatial_env store + ~rect_of:(Lui_query.Rects.get rects) + +(* Indexed and unindexed hit paths must agree on every point. *) +let test_spatial_hit_equiv () = + let store = spatial_hit_store () in + let rects = rects_of spatial_hit_rects in + let idx = spatial_index store rects in + let y = ref (-10.) in + while !y < 210. do + let x = ref (-10.) in + while !x < 310. do + let a = Lui_query.hit_path store rects ~x:!x ~y:!y in + let b = Lui_query.hit_path ~spatial:idx store rects ~x:!x ~y:!y in + if a <> b then + Alcotest.failf "hit_path diverged at (%.1f,%.1f): %s vs %s" !x !y + (String.concat "," (List.map string_of_int a)) + (String.concat "," (List.map string_of_int b)); + x := !x +. 7.5 + done; + y := !y +. 7.5 + done + +(* The promoted popover escapes its off-clip parent: indexed or not, + a click in its painted area must still land on it. *) +let test_spatial_hit_popup_escape () = + let store = spatial_hit_store () in + let rects = rects_of spatial_hit_rects in + let idx = spatial_index store rects in + Alcotest.(check (option int)) "popover hits" (Some 50) + (Lui_query.hit ~spatial:idx store rects ~x:10. ~y:130.); + Alcotest.(check (option int)) "clipped child misses" (Some 1) + (Lui_query.hit ~spatial:idx store rects ~x:10. ~y:100.) + +(* The interval index enumerates the same candidates in the same + order the unindexed walk would — a click deep in a 40-row list. *) +let test_spatial_hit_wide_list () = + let store = spatial_hit_store () in + let rects = rects_of spatial_hit_rects in + let idx = spatial_index store rects in + Alcotest.(check (option int)) "row 20 hits" (Some 120) + (Lui_query.hit ~spatial:idx store rects ~x:110. ~y:102.); + Alcotest.(check (option int)) "last row hits" (Some 139) + (Lui_query.hit ~spatial:idx store rects ~x:110. ~y:197.) + let () = Alcotest.run "lui_input" [ ( "focus", @@ -446,4 +535,11 @@ let () = [ Alcotest.test_case "flow" `Quick test_drag_flow ] ); ( "pointer", [ Alcotest.test_case "hover+press" `Quick - test_pointer_hover_press ] ) ] + test_pointer_hover_press ] ); + ( "spatial", + [ Alcotest.test_case "hit equivalence" `Quick + test_spatial_hit_equiv; + Alcotest.test_case "popup escape" `Quick + test_spatial_hit_popup_escape; + Alcotest.test_case "wide list" `Quick + test_spatial_hit_wide_list ] ) ] diff --git a/platform/native/lui_paint/test/dune b/platform/native/lui_paint/test/dune index 48940583..151119b6 100644 --- a/platform/native/lui_paint/test/dune +++ b/platform/native/lui_paint/test/dune @@ -1,4 +1,4 @@ (test (name lui_paint_test) (modules lui_paint_test) - (libraries lui_paint lui_store lui_scene lui alcotest)) + (libraries lui_paint lui_spatial lui_store lui_scene lui alcotest)) diff --git a/platform/native/lui_paint/test/lui_paint_test.ml b/platform/native/lui_paint/test/lui_paint_test.ml index 61132c07..442010b3 100644 --- a/platform/native/lui_paint/test/lui_paint_test.ml +++ b/platform/native/lui_paint/test/lui_paint_test.ml @@ -34,9 +34,11 @@ let scene () = ~mask_atlas:(Atlas.create ~bpp:1 ~w:64 ~h:64) ~color_atlas:(Atlas.create ~bpp:4 ~w:64 ~h:64) -let paint_with h t ?(width = 200) ?(height = 200) ?(scale = 1.) () = +let paint_with h t ?(width = 200) ?(height = 200) ?(scale = 1.) + ?spatial () = let s = scene () in - Lui_paint.paint h t s ~width ~height ~scale ~clear:(color 0 0 0 0); + Lui_paint.paint ?spatial h t s ~width ~height ~scale + ~clear:(color 0 0 0 0); s let test_fill_op () = @@ -675,6 +677,141 @@ let test_coverage_table () = in check bool "all props classified" true (missing = []) +(* ---------- spatial index ---------- *) + +(* Build an index over the same rects a layout-map hook hands the + walk — integral rects so [rect_snap] is the identity. *) +let spatial_of store f = + Lui_spatial.build Lui_paint.spatial_env store + ~rect_of:(fun id -> f id) + +let ops_of s = (s.ops, s.glyphs, s.wide) + +let test_spatial_paint_equiv () = + (* Everything the cull must respect at once: a scroll clip with + off-clip children, a promoted popup inside a fully offscreen + parent, a hidden subtree, a display:contents wrapper, a zero-area + spacer and z-reordered siblings. *) + let t = store () in + apply t + [ Lui_protocol.CreateNode (1, Column); + CreateNode (2, Scroll); + CreateNode (3, Column); CreateNode (4, Column); + CreateNode (5, Popover); + CreateNode (6, Column); CreateNode (7, Spacer); + CreateNode (8, Column); CreateNode (9, Column); + CreateNode (10, Column); CreateNode (11, Column); + SetProp (2, BackgroundValue, StringValue "#101010"); + SetProp (3, BackgroundValue, StringValue "#ff0000"); + SetProp (4, BackgroundValue, StringValue "#00ff00"); + SetProp (5, BackgroundValue, StringValue "#0000ff"); + SetProp (6, Visible, BoolValue false); + SetProp (6, BackgroundValue, StringValue "#ffffff"); + SetProp (8, DisplayValue, StringValue "contents"); + SetProp (9, BackgroundValue, StringValue "#ffff00"); + SetProp (10, ZIndex, IntValue 2); + SetProp (10, BackgroundValue, StringValue "#00ffff"); + SetProp (11, ZIndex, IntValue 1); + SetProp (11, BackgroundValue, StringValue "#ff00ff"); + InsertChild (1, 2, 0); + InsertChild (2, 3, 0); InsertChild (2, 4, 1); + InsertChild (4, 5, 0); + InsertChild (1, 6, 1); InsertChild (1, 7, 2); + InsertChild (1, 8, 3); InsertChild (8, 9, 0); + InsertChild (1, 10, 4); InsertChild (1, 11, 5) ]; + let rects = function + | 1 -> rect 0. 0. 200. 200. + | 2 -> rect 0. 0. 200. 100. + | 3 -> rect 0. 0. 200. 20. + | 4 -> rect 0. 300. 200. 20. (* fully offscreen + off-clip *) + | 5 -> rect 10. 50. 60. 30. (* promoted popup back onscreen *) + | 6 -> rect 0. 100. 200. 50. + | 7 -> rect 0. 150. 0. 0. + | 8 -> rect 0. 160. 200. 40. + | 9 -> rect 0. 160. 200. 40. + | 10 -> rect 0. 20. 100. 100. + | 11 -> rect 50. 20. 100. 100. + | _ -> rect 0. 0. 0. 0. + in + let layout = layout_map rects in + let idx = spatial_of t rects in + let a = paint_with (hooks ~layout ()) t () in + let b = paint_with (hooks ~layout ()) t ~spatial:idx () in + check bool "indexed paint emits identical scene" true + (ops_of a = ops_of b) + +let test_spatial_visit_bound () = + (* A 300-row column under a 100px viewport: the interval index must + visit only the band, not every mounted row. *) + let t = store () in + let ops = ref [ Lui_protocol.InsertChild (1, 2, 0); + CreateNode (2, Column); CreateNode (1, Root) ] in + for i = 0 to 299 do + ops := + InsertChild (2, 10 + i, i) + :: CreateNode (10 + i, ViewThatFits) + :: !ops + done; + apply t (List.rev !ops); + let rects = function + | 1 -> rect 0. 0. 200. 100. + | 2 -> rect 0. 0. 200. 6000. + | id -> rect 0. (float_of_int (id - 10) *. 20.) 200. 20. + in + let idx = spatial_of t rects in + let count ?spatial () = + let n = ref 0 in + Lui_paint.walk ~scale:1. ~viewport:(rect 0. 0. 200. 100.) t + ~layout:(layout_map rects) + ~enter:(fun () _ -> incr n; + { Lui_paint.b_emit = false; b_down = true; b_ctx = () }) + ~leave:(fun _ _ -> ()) + ?spatial:(Option.map + (fun i -> (i, Lui_spatial.Region (rect 0. 0. 200. 100.))) + spatial) + (); + !n + in + let full = count () and pruned = count ~spatial:idx () in + check int "unindexed visits everything" 302 full; + check bool "indexed visits the band only" true (pruned <= 12) + +let test_spatial_degenerate_kept () = + (* A zero-area child inside a wide parent must still run its + emission path — the index keeps zero-bound subtrees reachable + for region queries. *) + let t = store () in + let ops = + ref [ Lui_protocol.InsertChild (1, 2, 0); + CreateNode (2, Column); CreateNode (1, Root) ] + in + for i = 0 to 39 do + ops := + InsertChild (2, 10 + i, i) + :: CreateNode (10 + i, ViewThatFits) + :: SetProp (10 + i, BackgroundValue, StringValue "#ff0000") + :: !ops + done; + (* one zero-area box at a scrolled-off position *) + ops := + InsertChild (2, 500, 40) + :: CreateNode (500, Column) + :: SetProp (500, BackgroundValue, StringValue "#00ff00") + :: !ops; + apply t (List.rev !ops); + let rects = function + | 1 -> rect 0. 0. 200. 200. + | 2 -> rect 0. 0. 200. 2000. + | 500 -> rect 0. 5000. 0. 0. + | id -> rect 0. (float_of_int (id - 10) *. 40.) 200. 40. + in + let idx = spatial_of t rects in + let a = paint_with (hooks ~layout:(layout_map rects) ()) t () in + let b = + paint_with (hooks ~layout:(layout_map rects) ()) t ~spatial:idx () + in + check bool "degenerate ops preserved" true (ops_of a = ops_of b) + let () = run "lui_paint" [ ("color", @@ -728,5 +865,9 @@ let () = test_case "placeholder unmasked" `Quick test_secure_placeholder_unmasked; test_case "mask units" `Quick test_secure_mask_units; test_case "masked offset map" `Quick test_secure_byte_of_masked ]); + ("spatial", + [ test_case "paint equivalence" `Quick test_spatial_paint_equiv; + test_case "visit bound" `Quick test_spatial_visit_bound; + test_case "degenerate kept" `Quick test_spatial_degenerate_kept ]); ("coverage", [ test_case "all props" `Quick test_coverage_table ]) ] diff --git a/platform/native/perf/dune b/platform/native/perf/dune index 92260433..884a53d9 100644 --- a/platform/native/perf/dune +++ b/platform/native/perf/dune @@ -2,7 +2,7 @@ (name lui_perf) (modules harness scenarios) (libraries lui lui_store lui_scene lui_paint lui_layout lui_raster - lui_host unix)) + lui_host lui_input lui_spatial unix)) (executable (name main) @@ -15,3 +15,4 @@ (modules lui_perf_test) (libraries lui_perf lui lui_store lui_scene lui_paint lui_layout lui_raster lui_host alcotest unix)) + diff --git a/platform/native/perf/scenarios.ml b/platform/native/perf/scenarios.ml index 62a81bf3..ba717bc3 100644 --- a/platform/native/perf/scenarios.ml +++ b/platform/native/perf/scenarios.ml @@ -248,6 +248,77 @@ let memory ~n ~item_h ~width ~height = row name "RSS after first frame" (mb after_frame) "MiB"; ] +(* Pointer-event cost over a mounted virtual list: the store is + static and the hit mirror is resolved once, then every pointer + event pays one point query. The unindexed path walks the whole + tree; the spatial index descends only toward the hit band. Also + reports an indexed static-frame paint — the viewport-cull case the + same index serves. *) +let hit ~n ~item_h ~width ~height = + let name = Printf.sprintf "hit-%dk" (n / 1000) in + let t = H.create ~width ~height ~scale:1. () in + ignore (H.apply t (mount_vlist ~n ~item_h)); + ignore (H.layout t); + ignore (H.paint t); + (* The mirror the window driver's populate fills: resolved rects — + shifted engine rects here, resolve_rect is identity for these + plain rows. *) + let rects = Lui_query.Rects.create () in + List.iter + (fun id -> + match H.rect_of t id with + | Some r -> Hashtbl.replace rects id r + | None -> ()) + (Lui_store.preorder (H.store t)); + let idx = + Lui_spatial.build Lui_paint.spatial_env (H.store t) + ~rect_of:(Lui_query.Rects.get rects) + in + (* Probe points: three depths inside the visible viewport. *) + let probes = + [ (float_of_int width *. 0.5, 20.); + (float_of_int width *. 0.5, float_of_int height *. 0.5); + (float_of_int width *. 0.9, float_of_int height -. 20.) ] + in + let iters = 30 in + let time_hits spatial = + let acc = ref [] in + for _ = 1 to iters do + let t0 = H.now () in + List.iter + (fun (x, y) -> + ignore (Lui_query.hit_path ?spatial (H.store t) rects ~x ~y)) + probes; + acc := (H.now () -. t0) *. 1000. :: !acc + done; + H.stats_of !acc + in + let unindexed = time_hits None in + let indexed = time_hits (Some idx) in + (* Static-frame paint with a warm index: what a hover-only repaint + costs once geometry is already indexed. *) + let paint_idx = + let acc = ref [] in + for _ = 1 to 10 do + let t0 = H.now () in + Lui_paint.paint ~spatial:idx (H.paint_hooks t) (H.store t) + (H.scene t) ~width ~height ~scale:1. ~clear:(Lui_scene.color 0 0 0 0); + acc := (H.now () -. t0) *. 1000. :: !acc + done; + H.stats_of !acc + in + [ + ms_row name "hit_path p50" unindexed.p50; + ms_row name "hit_path p50 indexed" indexed.p50; + ms_row name "paint static p50 indexed" paint_idx.p50; + ms_row name "index build" + (snd + (H.timed (fun () -> + ignore + (Lui_spatial.build Lui_paint.spatial_env (H.store t) + ~rect_of:(Lui_query.Rects.get rects))))); + ] + (* ---------- suite ---------- *) let default_width = 1280 @@ -272,6 +343,8 @@ let all ?(scale_sizes = false) () = vlist ~n ~frames ~velocity:40. ~item_h:28. ~width:w ~height:h) vlists); damage ~n:n5 ~item_h:20. ~width:w ~height:h; + hit ~n:(List.nth vlists (List.length vlists - 1)) ~item_h:28. + ~width:w ~height:h; text ~n:ntx ~len:48 ~width:w ~height:h; first_frame ~n:n10 ~item_h:20. ~width:w ~height:h; memory ~n:nrss ~item_h:28. ~width:w ~height:h;