Skip to content

feat(graph): experiment with tree-native viewport culling and shared batch visibility - #428

Draft
draedful wants to merge 13 commits into
v2from
experiment/tile-visibility-culling
Draft

draedful wants to merge 13 commits into
v2from
experiment/tile-visibility-culling

Conversation

@draedful

@draedful draedful commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Experiment. This PR is for reviewing the implementation and comparing it on the preview stand; it is not meant to be merged as is.

Behavior and architecture

Viewport culling belongs to the layer's component tree. Each configured root has one TreeCulling coordinator and one shared TileIndex. 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.

Tree retains topology, sibling order, bounds and per-node lifecycle state. TreeCulling owns 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. markDirty and update refresh 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 ordered afterRender hooks.

  • Geometry changes update the index without rebuilding unchanged preparation order, paint order or ancestor eligibility. Membership, sibling order and traversal permission invalidate those caches separately. Fully covered views reuse full traversal orders; partial views select through tiles or a linear scan when the viewport is dense.
  • Components and connection batches paint from the same finalized visibility decision. Updates and between-frame visibility reads use current geometry. Batch membership changes before drawing, including the first frame.
  • Each node reports its own bounds: a visible anchor or custom child remains reachable outside an offscreen parent's bounds. Unknown bounds remain eligible. World-space hit queries continue working outside the viewport.
  • Existing semantic gates remain: hidden entities, missing endpoints, delegated rendering, custom paint veto and explicit child suppression. If a parent suppresses children during rendering, already prepared children complete their lifecycle without painting.
  • Connection bounds include labels, curves, arrows and stroke extents before selection. Label subscriptions exist only for enabled, nonempty labels; shared font-size state avoids redundant camera notifications. Dynamic toggles, text changes, offscreen updates and disposal remain supported.
  • Deferred external snapshots can refresh in willIterate() before the first visible repaint. A subclass's shouldRender = false after super.willIterate() and first-mount veto remain respected.
  • Standard label paint bounds remain separate from protected decoration bounds. Custom labels prepare geometry in updateLabelGeometry() and draw in renderLabelText(). The Storybook example now uses those prepared bounds and applies its selected-only condition before culling and hit testing.
  • Retained children keep index registrations; removal releases registrations and queued work. Reparenting, local z-order, subtree suppression/resume and independent roots remain covered.

Configuration and customization

EXP_VISIBILITY_CULLING defaults to true and is read each frame. Disabling it restores full traversal while retaining the same viewport paint gate. system.VISIBILITY_TILE_SIZE defaults 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. firstIterate tracks initialization; firstRender remains 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 through markDirty or update; shared custom visibility predicates remain supported when a chunk refreshes.

Validation of 8135039

  • All 10 packages built; all 10 packages and both applications passed typechecking.
  • Unit tests: 92 suites, 817 passed, 4 existing skips, 6 existing todos; all 9 snapshots passed. Release checks: 39 passed. Type-configuration checks: 5 passed.
  • Six new regression cases cover unchanged dense visibility, callback-driven geometry/viewport/ancestor changes, first hidden publication for a new offscreen node, and restoring a descendant before batch paint after its parent suppressed painting.
  • The work regression failed on the previous PR with 23 redundant eligibility checks and 23 publication calls; the optimized path requires zero of each while preserving preparation and drawing. The five compatibility cases also pass against the previous PR. Weakening the structure-revision guard makes the new offscreen-node test fail.
  • Chromium E2E: 271 passed, 1 existing skip, no retries or snapshot updates. Independent review found no actionable issue in the new change.
  • Formatting passes. Lint has zero errors and one publishVisibility complexity warning (27, configured maximum 20). The method already exceeded this limit before this iteration.
  • Packed-package contracts and the earlier dedicated custom-Path2D pixel probe were not repeated for this internal selection/publication change. Their prior results apply to 7b543ee, not this revision.

Fresh Playground comparison

Exact revisions: base e8c66b511ec01f22ab9b9fa3690ab73e0404d215, previous PR 7b543eeaf233227d6c9b34e7abeefe70fbf65fab, final 8135039154c7d300e4e5b5375c37f0e20567ce0d. Common Storybook fixtures are pinned to c80debbf6459953a9bddb2bba7b70ad8fe71444b.

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.

Playground view, scale 0.01 Base Previous PR, ON Previous PR, OFF Final PR, ON Final PR, OFF
Full 30.434 / 30.210 32.387 / 32.762 32.410 / 32.191 32.393 / 32.359 32.445 / 32.446
Almost full 28.402 / 27.816 32.704 / 32.076 32.540 / 32.050 32.222 / 31.698 32.745 / 32.218

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

  • Final near-full ON remains 13.45% / 13.96% slower than base. Full-view ON remains 6.44% / 7.11% slower than base. This iteration does not eliminate the overall dense-view regression.
  • Full visibility was already using a cached fast path, so this change primarily targets partial selection and publication.
  • Label-heavy and arrow-enabled timing matrices, sparse views, Nirvana/Stress scenes, downstream product integration, other devices, native-cache memory, GPU time and input-to-pixel latency were not repeated in this targeted run.

The layer, renderer and public event-model architecture is unchanged. Existing component and batch customization paths remain supported.

draedful and others added 2 commits October 20, 2026 13:14
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>
@sourcery-ai

sourcery-ai Bot commented Oct 20, 2026

Copy link
Copy Markdown

Reviewer's Guide

This 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

Change Details Files
Adds sparse world-space visibility indexing for efficient viewport queries.
  • Indexes each component in exactly one tile, strip, large-item, or unbounded bucket.
  • Uses exact rectangle intersection checks after widened tile queries.
  • Supports O(1) bucket updates with swap-remove and handles moves, deletes, negative coordinates, and oversized bounds.
  • Adds randomized brute-force correctness coverage.
packages/graph/src/lib/TileIndex.ts
packages/graph/src/lib/TileIndex.test.ts
Introduces culling-aware tree traversal while preserving draw order and component lifecycle behavior.
  • Selects indexed viewport hits plus pending, fresh, and recently active children.
  • Falls back to full traversal when culling is disabled or candidate density is high.
  • Maintains full-walk renderOrder values and marks skipped nodes for hit-test iteration semantics.
  • Adds regression coverage for ordering, pending updates, leavers, bounds changes, removals, and fallback paths.
packages/graph/src/lib/TreeCulling.ts
packages/graph/src/lib/TreeCulling.test.ts
packages/graph/src/lib/Tree.ts
packages/graph/src/lib/CoreComponent.ts
Enables viewport culling for graph blocks, connections, and groups using reported world-space bounds.
  • Reports GraphComponent hit boxes to parent indexes.
  • Configures viewport, tile size, margin, and feature-flag behavior for the three graph containers.
  • Ensures render requests propagate to culling ancestors and child removal updates indexes.
  • Adds graph-level tests for off-screen iteration, visibility transitions, state updates, hit testing, and connections following off-screen blocks.
packages/graph/src/components/canvas/visibilityCulling.ts
packages/graph/src/components/canvas/visibilityCulling.test.ts
packages/graph/src/components/canvas/GraphComponent/index.ts
packages/graph/src/components/canvas/blocks/Blocks.ts
packages/graph/src/components/canvas/connections/BlockConnections.ts
packages/graph/src/components/canvas/groups/BlockGroups.ts
Fixes pre-existing tree ordering cache and z-index relocation bugs exposed by selective traversal.
  • Removes nodes from their old z-index group before assigning a new z-index.
  • Invalidates the order cache whenever a z-index group changes, including non-empty groups.
  • Adds a regression test ensuring nodes are walked once in the correct order.
packages/graph/src/lib/Tree.ts
packages/graph/src/lib/Tree.spec.ts
Adds configuration and Storybook controls for the experimental culling implementation.
  • Defaults EXP_VISIBILITY_CULLING to enabled and reads it each frame.
  • Documents tile size and viewport margin constants plus the feature flag.
  • Exposes the flag as a GraphEditor Storybook control.
packages/graph/src/store/settings.ts
packages/graph/src/graphConfig.ts
packages/graph/docs/system/graph-settings.md
apps/storybook/src/stories/main/GraphEditor.stories.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gravity-ui-bot

Copy link
Copy Markdown
Contributor

Preview is ready.

@draedful draedful left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four reproducible findings from comparing base e8c66b5 with head 93d1dbf. Each inline comment includes reproduction steps, observed behavior, and a suggested fix.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Add a built-in block at {x: 100, y: -400, width: 100, height: 100} with 20 OUT anchors, plus a second block at (50000, 50000).
  2. Use a 1000 × 800 viewport, camera {x: 0, y: 0, scale: 0.5}, and let the initial updates settle.
  3. Anchor index 15 is at world position (200, 168), inside the viewport. The block's bottom is -300, outside the culling boundary -160.
  4. Spy on that anchor's render(), call graph.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.

Comment on lines +71 to +74
public delete(node: Tree): void {
this.index.delete(node);
this.pending.delete(node);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Create 1000 built-in blocks with x = (i % 100) * 8, y = Math.floor(i / 100) * 60, width 5, height 10, and no anchors.
  2. Start the graph, use a 1000 × 800 viewport at {x: 0, y: 0, scale: 1}, and let the initial updates settle.
  3. Keep the graph mounted, call graph.setEntities({blocks: [], connections: []}), and advance eight frames.
  4. Inspect the former blocks container: children.size === 0 and culling.index.size === 0, but culling.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.

Comment thread packages/graph/src/lib/CoreComponent.ts Outdated
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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. Add a near block at (100, 100) and a 100 × 100 block at (50000, 50000) with one OUT anchor.
  2. Start the graph with a 1000 × 800 viewport at {x: 0, y: 0, scale: 1}, and let updates settle.
  3. Get the far anchor's view component and const point = anchor.getPosition().
  4. Request another graph-layer render and advance three frames.
  5. Compare graph.getElementOverPoint(point, [Anchor]) with graph.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.

Comment thread packages/graph/src/lib/TileIndex.ts Outdated
* 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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 candidate

The 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.

@draedful draedful changed the title feat(graph): experiment: walk only components near the viewport via a tile index feat(graph): experiment with tree-native viewport culling and shared batch visibility Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants