Repository navigation
Conversation
Blocks, connections and groups containers keep their children in a world-space TileIndex. Each frame a container walks the children whose bounds intersect the camera viewport (plus a margin), children that requested a render, newly appended children and children that just left the view, in the same zIndex/renderOrder order as a full walk. It falls back to a full walk when most children are near the viewport. - performRender() marks the branch pending in culling ancestors, so pending props/state, first iterate and anchor updates of off-screen components are still processed in the next frame. - renderOrder is assigned to every child when the z-order is rebuilt, and isIterated() is true for children skipped by culling, so point hit tests keep today's results. - EXP_VISIBILITY_CULLING setting (default true), VISIBILITY_TILE_SIZE and VISIBILITY_CULLING_MARGIN constants. - Fix Tree.updateZIndex leaving the node in its previous zIndex group (a raised block was walked and drawn twice) and removeZIndex keeping removed nodes in the cached order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lets GraphStressTest and NirvanaMaxGraphTest be compared with EXP_VISIBILITY_CULLING on and off on the preview stand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThis experimental PR adds a tile-backed world-space visibility index and culling-aware tree traversal so graph containers walk only viewport-near components while preserving draw order, updates, hit testing, and lifecycle behavior; it also fixes related Tree ordering bugs and exposes configurable Storybook/documented controls. File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Preview is ready. |
| public setHitBox(minX: number, minY: number, maxX: number, maxY: number, force?: boolean) { | ||
| this.hitBox.update(minX, minY, maxX, maxY, force); | ||
| // isVisible() checks the hit box, so the parent's culling uses the same rect. | ||
| if (!this.hitBox.destroyed) this.setCullingBounds(minX, minY, maxX, maxY); |
There was a problem hiding this comment.
[P2] Include visible descendants in the bounds used to cull a block
This registers the component's own hit box as the bounds for skipping its entire subtree. A built-in Anchor can extend outside its Block, so the block can be outside the expanded viewport while a child still needs to be drawn. Before this change, the block skipped its own rendering but its children were still visited.
Reproduction with the existing graph-test setup and default constants:
- Add a built-in block at
{x: 100, y: -400, width: 100, height: 100}with 20OUTanchors, plus a second block at(50000, 50000). - Use a
1000 × 800viewport, camera{x: 0, y: 0, scale: 0.5}, and let the initial updates settle. - Anchor index 15 is at world position
(200, 168), inside the viewport. The block's bottom is-300, outside the culling boundary-160. - Spy on that anchor's
render(), callgraph.getGraphLayer().performRender(), and advance three frames.
On base e8c66b5 and head 93d1dbf with EXP_VISIBILITY_CULLING: false, the anchor renders. With culling enabled, it receives no render call. This uses built-in components; the assertion runs the real graph lifecycle with a mocked canvas, rather than a screenshot comparison. Anchor overflow is also described in packages/graph/docs/components/anchors.md.
Suggested fix: use conservative bounds for the whole renderable subtree, or leave branches with descendants outside their bounds uncullable until those bounds can be supplied. A fixed viewport margin does not cover arbitrary anchor counts or custom layouts. Add the above regression test so a visible child continues rendering when its parent is outside the viewport.
| public delete(node: Tree): void { | ||
| this.index.delete(node); | ||
| this.pending.delete(node); | ||
| } |
There was a problem hiding this comment.
[P2] Release retained child references when the container becomes empty
Deleting a child removes it from the index and pending, but leaves references in active and potentially fresh. After the last child is removed, Tree._walkDown() returns at the empty-children check before calling select(), so no later traversal clears those arrays.
Reproduction through the graph API:
- Create 1000 built-in blocks with
x = (i % 100) * 8,y = Math.floor(i / 100) * 60, width 5, height 10, and no anchors. - Start the graph, use a
1000 × 800viewport at{x: 0, y: 0, scale: 1}, and let the initial updates settle. - Keep the graph mounted, call
graph.setEntities({blocks: [], connections: []}), and advance eight frames. - Inspect the former blocks container:
children.size === 0andculling.index.size === 0, butculling.active.length === 1000; all 1000 retained components have destroyed hit boxes.
I also reproduced the same clear/remove sequence on Tree with WeakRefs and explicit GC: the empty root retained 1000/1000 removed children with culling, versus 0/1000 without a culling instance. The graph-level check passes on base e8c66b5 and fails on head 93d1dbf. This retains the previous contents while the container stays alive and empty; it is not a claim of unbounded accumulation across nonempty replacements.
Suggested fix: when deletion empties the index, clear active and fresh as well. Add a regression for clearing a mounted graph. Avoid unconditionally clearing the index in Tree.clearChildren(), because normal child reconciliation also calls that method for children that remain mounted.
| const iterateId = this.__comp.context.globalIterateId; | ||
| // A child skipped by its parent's visibility culling was out of view with nothing to process, | ||
| // so for this traversal it counts as iterated. | ||
| return this.__comp.iterateId === iterateId || this.__comp.treeNode.isSkippedByCulling(iterateId); |
There was a problem hiding this comment.
[P2] Preserve point hit testing for descendants of a culled branch
isSkippedByCulling() only checks the immediate parent. A skipped Block therefore counts as iterated because its Blocks parent owns culling, but its Anchor children do not: their immediate parent is the block, which has no child culling. GraphComponent.onHitBox() then rejects those anchors.
Reproduction with default block/anchor sizes:
- Add a near block at
(100, 100)and a100 × 100block at(50000, 50000)with oneOUTanchor. - Start the graph with a
1000 × 800viewport at{x: 0, y: 0, scale: 1}, and let updates settle. - Get the far anchor's view component and
const point = anchor.getPosition(). - Request another graph-layer render and advance three frames.
- Compare
graph.getElementOverPoint(point, [Anchor])withgraph.getElementsOverRect({x: point.x - 1, y: point.y - 1, width: 2, height: 2}, [Anchor]).
On head 93d1dbf with culling enabled, the point query returns undefined while the rectangle query still returns the anchor. Base e8c66b5 and head with culling disabled return the anchor from both queries. This changes the existing world-coordinate query behavior even though the anchor remains registered in the hit-test index.
Suggested fix: propagate the fact that an entire branch was skipped by visibility culling to its descendants, while retaining the distinction from children intentionally excluded by shouldRenderChildren === false. Add a point-query regression for an off-screen anchor. This is separate from subtree bounds: even correct bounds leave a fully off-screen branch eligible for culling.
| * It is an upper bound of the query result and a measure of its cost. | ||
| */ | ||
| public countCandidates(rect: TTileRect, limit: number): number { | ||
| let count = this.unbounded.length + this.large.length; |
There was a problem hiding this comment.
[P2] Count the large bucket only once before deciding to walk all children
The initial count includes this.large.length, and scan() immediately visits the same large array again. Large items are therefore counted twice, which can trigger the 75% full-walk fallback even when substantially fewer candidates need testing.
Minimal reproduction:
const index = new TileIndex<string>({tileSize: 1024});
index.set("large", 10000, 10000, 20000, 20000);
index.countCandidates(
{minX: 0, minY: 0, maxX: 100, maxY: 100},
Infinity,
); // 2, although there is only one candidateThe fallback consequence is also reproducible: create 1000 children, all outside that viewport, at x = 10000 + i * 1024, y = 10000. Give the first 400 children size 10000 × 10000 and the remaining 600 size 100 × 100. With default culling options, four successive traversals visit [1000, 1000, 1000, 1000] children. For the same data with fullWalkShare: Infinity, they visit [1000, 1000, 0, 0]: only the initial lifecycle walks are needed.
Suggested fix: initialize count with this.unbounded.length only; scan() already counts the large bucket. Add a large-item candidate-count test and a fallback regression with a mix of large and small off-screen items. Otherwise large groups or long diagonal connections can disable the intended traversal optimization while still paying for the index.
Behavior and architecture
Viewport culling belongs to the layer's component tree. Each configured root has one
TreeCullingcoordinator and one sharedTileIndex. A frame prepares visible, new, or dirty components and the ancestors required to reach them, resolves final visibility, and then paints in local z-index order. Clean offscreen components are skipped; dirty offscreen components apply data, reconcile children and update geometry without entering paint hooks.Treeretains topology, sibling order, bounds and per-node lifecycle state.TreeCullingowns update queues, selection, paint traversal and completion of prepared work. Layers, drawing contexts and component renderers retain their existing responsibilities.Dense views reuse preparation order, parent positions and valid exact selections. Selection stamps are assigned while collecting candidates, avoiding a second pass. Visibility publication skips unchanged membership only when the previous publication matches the structure revision and the current exact selection remains valid. New nodes and custom-hook changes still take the complete publication path; changed-visibility callbacks invalidate reuse for later nodes. Geometry, topology, viewport and custom-hook mutations invalidate the corresponding caches while preserving registration order and local paint order.
Batch geometry snapshots are separate from viewport membership.
markDirtyandupdaterefresh only the affected item; visibility changes reuse unchanged line and arrow geometry. Large draw chunks lazily cache bounded spatial subpaths after viewport membership changes. These subpaths are composed into the original draw operation, preserving transparent overlap,evenodd, compositing, first-visible style and orderedafterRenderhooks.willIterate()before the first visible repaint. A subclass'sshouldRender = falseaftersuper.willIterate()and first-mount veto remain respected.updateLabelGeometry()and draw inrenderLabelText(). The Storybook example now uses those prepared bounds and applies its selected-only condition before culling and hit testing.Configuration and customization
EXP_VISIBILITY_CULLINGdefaults totrueand is read each frame. Disabling it restores full traversal while retaining the same viewport paint gate.system.VISIBILITY_TILE_SIZEdefaults to 1024 and is read when the layer is created. Selection uses the exact viewport and conservative own bounds.Geometry and children are prepared before painting. Initialize ports, hit boxes and custom decoration bounds in constructors or update hooks. A decoration whose bounds are calculated only inside rendering cannot make an initially offscreen component discoverable before its first paint. The documented
updateLabelGeometry()path supports same-frame decoration-only viewport intersections, hit testing and dynamic selection.firstIteratetracks initialization;firstRenderremains true until actual painting.Custom batch items may provide optional world-space
getPathBounds()hints; built-in connections and arrows provide them automatically. Unknown or invalid hints never cull a path. Custom geometry changes outside normal connection state/props updates must invalidate that item throughmarkDirtyorupdate; shared custom visibility predicates remain supported when a chunk refreshes.Validation of
8135039publishVisibilitycomplexity warning (27, configured maximum 20). The method already exceeded this limit before this iteration.7b543ee, not this revision.Fresh Playground comparison
Exact revisions: base
e8c66b511ec01f22ab9b9fa3690ab73e0404d215, previous PR7b543eeaf233227d6c9b34e7abeefe70fbf65fab, final8135039154c7d300e4e5b5375c37f0e20567ce0d. Common Storybook fixtures are pinned toc80debbf6459953a9bddb2bba7b70ad8fe71444b.Chrome for Testing 145, Apple M3 Pro / hardware Metal, DPR 2, CPU 1x. Playground contains 10,476 blocks and 6,568 connections. Three rotated repeats per mode and view, 30 warm-up steps and 60 measured updates per pan/zoom phase: 30 mounts, 60 phases, 3,600 measured GraphLayer updates. Labels and arrows are off. Primary timing excludes profiling, stage wrappers and path counters.
Values are median mean main-thread TaskDuration milliseconds per GraphLayer update, pan / zoom. They are not presented FPS or input-to-pixel latency.
Full overview uses 1600x1600 and asserts that all block bodies remain fully visible throughout motion. Near-full uses 1600x1000; it starts with 10,134 fully visible blocks. OFF disables traversal filtering while retaining the same viewport paint gate; base has no tile culling.
Near-full ON medians improve by 1.47% for pan and 1.18% for zoom. Pan ranges narrowly overlap: previous 32.354–35.283 ms, final 32.061–32.372 ms. Zoom ranges are separated in these three observations: previous 31.954–33.869 ms, final 31.503–31.742 ms. This is a modest improvement in this controlled run, not a claim of statistical significance or broad hardware equivalence.
Full-view ON medians are +0.02% / -1.23% versus the previous PR, with overlapping repeat ranges; no material full-view speedup is established. OFF ranges also overlap.
First GraphLayer traversal, median base / previous ON / final ON: full 393.3 / 466.0 / 472.6 ms; near-full 421.2 / 479.6 / 473.8 ms. First publication cannot reuse a prior visibility snapshot; this optimization does not remove startup cost. These values measure traversal CPU time, not first presented pixels.
Selection and publication diagnostic
A separate run measured six coordinator methods without a CPU profiler or per-node timers: 24 mounts, 48 phases, 2,880 updates. Near-full ON, the median of the per-phase combined selection/publication cost fell from 3.087 to 2.560 ms for pan and 2.555 to 2.178 ms for zoom. Full overview remained around 0–0.005 ms for this stage.
These wrappers can perturb timings. In the instrumented zoom run, paint time varied enough that the total instrumented GraphLayer median increased despite the lower selection/publication median. Stage deltas are therefore diagnostic evidence, not an additive explanation of the separate primary timing. No OFF speedup is established.
All 36 screenshot comparisons are exact RGBA and byte matches, covering initial, pan-extreme and zoom-extreme states across base, previous PR and final PR with culling ON/OFF. This verifies those captured states, not every dynamic frame. Served bundles and all embedded sources were checked against the pinned Git revisions before and after capture.
Performance limits
The layer, renderer and public event-model architecture is unchanged. Existing component and batch customization paths remain supported.