Zooming out should help you understand a graph. In our first large-graph test, it made the editor almost unusable.
Up close, we had roughly 50 FPS. Fit the same 1,000 entities and 5,000 connections into one window, and we dropped to 0.41 FPS. One frame took more than two seconds.
The optimized overview now averages 77.33 FPS, with a mean frame time of 12.93 ms. That is roughly a 188× improvement in average frame rate.
These measurements come from a test machine with a six-core Intel Core i7-9750H at 2.60 GHz, 32 GiB RAM, and an AMD Radeon Pro 5500M with 4 GB VRAM, running macOS 26.7.1. Note that discrete GPU - it requires the data to be actually transmitted from system memory to video memory via the bus. The results on the newer ARM-based machine would probably much better from the start.
There is a deliberate representation change behind that number: distant nodes become counted clusters, and their connections become directed bundles. Keep every node and wire individually drawn, and the optimized overview reaches 9.53 FPS. A substantial improvement, but still below our 30 FPS target.
The engineering challenge was to make the editor spend its time on information the user could actually see and use.
What we’re building

Graph ECS is a lightweight C++23 data-model library and native authoring environment for systems whose data and relationships need to be edited as graphs. Potential uses include processing pipelines, AI orchestration, shader graphs, state machines, and simulations. The project supplies the modeling and editing infrastructure; applications supply execution semantics.
Its building blocks are deliberately separate:
- Entities provide stable identities and appear as nodes on the canvas.
- Component schemas define fields, constraints, and typed input/output slots.
- Component instances hold data and can be shared between entities through distinct attachments.
- Connection tables hold independent sets of relationships over those entities, including slot-to-slot links and entity-level relationships.
For example, two entities can share one component instance while keeping separate connections through their own attachments. The same entities can also appear in different relationship tables, each with its own graph layout.
The editor lets users author schemas, edit component data, connect nodes, inspect diagnostics, and undo changes. Incomplete work remains editable: invalid connections and unavailable schemas produce diagnostics without silently deleting the underlying records.
That matters for rendering. A fast overview still needs to communicate direction, multiplicity, and validation problems—and let users navigate back to the exact objects they need to repair.
Our technology stack consists of:
- C++23 for the data-model library and native editor.
- CMake for build configuration, with Ninja used in the measured setup.
- SDL3 for windowing, input, and rendering; the measured macOS runs use Metal.
- Dear ImGui’s docking branch for the editor panels and imgui-node-editor for graph interaction.
- nlohmann/json for JSON parsing and serialization.
- Native File Dialog Extended for native Open and Save As dialogs.
- Catch2 for correctness tests and core benchmarks.
The optimization kept Dear ImGui’s official SDL3 renderer backend.
The problem: the overview exposed the rendering cost
Our initial canvas followed a straightforward approach: translate the model into ordinary node-editor widgets and connections, then let the UI libraries draw them.
The data model was already reasonably quick. A separate headless benchmark loaded and fully validated its test document in about 129 ms. That benchmark excluded UI rendering and distributed connections across two tables; the native rendering test placed all 5,000 connections in the active table.
The native overview told a very different story. Its 2,433.92 ms average frame broke down into approximately:
- 28.66 ms constructing the UI.
- 2,396.58 ms converting and submitting render data.
- 8.37 ms presenting the frame.
The overview submitted up to 629,635 vertices. A CPU sample placed 744 of 746 main-thread samples inside the ImGui SDL renderer backend. Inspection of our pinned backend revealed repeated conversion of the remaining vertex-buffer tail for each draw command, followed by submission of that tail to SDL. Large meshes split across many commands made this path particularly expensive.
This gave us a concrete direction: reduce submitted geometry, batch its replay, and stop rebuilding unchanged results.
The approaches we chose—and why
1. Give geometry a spatial owner
We use a quadtree to divide the graph into progressively smaller square cells. It serves two purposes: finding what overlaps the viewport and assigning ownership to cached geometry.
This makes panning a visibility change. Cells leaving the screen stop being submitted; newly visible cells need construction only when their meshes are missing or stale. Returning to a previously visited area can reuse its cached work.
Connections require more care than nodes. A curve can cross the viewport while both endpoints are offscreen, so visibility uses conservative curve bounds, including stroke extents. Each cached piece has one owner, preventing overlapping queries from drawing a connection twice.
Why this approach: visibility and cache ownership share one spatial structure, giving us a practical unit for both reuse and local invalidation.
Caching triangles alone would still leave us repeatedly resolving the graph, laying out labels and pins, choosing detail levels, and forming bundles.
We retain the model projection and validation results, node-body geometry, selected detail level, visible-cell list, aggregation results, and cell meshes. An unchanged frame reuses them. Recently hidden meshes remain in a 64 MiB LRU cache per view so a return pan can be cheap. Visible meshes are pinned; other caches consume additional memory, so this is not a total application memory cap.
Edits invalidate dependencies. Moving a node affects its old and new owners, incident wires, and bundles whose endpoints change. Previously built coarser levels must also become stale, even while hidden. Font-atlas changes or growth of the quadtree root can still require broad rebuilds.
Why this approach: a small edit should cause work proportional to its affected view dependencies. An idle frame should reuse the answers it already has.

3. Change the representation with zoom
A full node body is useful when its labels and pins are readable. At whole-graph scale, those details mostly become visual noise.
We select a quadtree depth from zoom, targeting cells roughly 24–48 logical pixels across. A 15% hysteresis band prevents tiny zoom changes from repeatedly switching detail levels. Panning alone does not reevaluate the level.
At coarse levels, entities sharing a cell become a counted cluster. Directed connections between the same cell pair become bundles, separated by validation status. Arrows preserve direction; thickness conveys multiplicity. Internal connections contribute to cluster status and hover summaries. Invalid and unresolved relationships remain represented. Clicking a cluster zooms into its region.
This is cell-based aggregation, so nearby entities across a cell boundary remain separate. Users can choose the level at which merging stops, or disable merging entirely. These settings affect presentation and preserve the canonical entities and connections.
Curves also use adaptive subdivision: distant wires need fewer segments, while close-up wires retain more detail.
Why this approach: an overview needs to reveal structure. The 9.53 FPS unmerged result makes the tradeoff measurable: geometry reuse helps, but aggregation is what makes this full overview practical.
The unreadable mess

turns into slightly more manageable representation:

4. Build independent cell meshes on workers
Spatial ownership gives us independent pieces of geometry to build. A shared pool of two to four std::jthread workers handles cell meshes; the recorded run used four.
Workers receive immutable snapshots of prepared geometry, endpoints, validation states, and glyph metrics. They never touch the mutable model, ImGui, SDL, font atlases, or GPU resources. The UI thread collects results before publishing the frame.
Why this approach: cell construction can run in parallel while editor and renderer ownership stay clear. The join is synchronous, so a cold miss can still delay a frame. We have no controlled one-worker versus four-worker comparison and cannot assign a separate speedup to threading.
5. Keep gestures separate from scene rebuilding
During node and connection drags, we freeze detail selection, visibility queries, aggregation, and persistent cell-mesh construction. The moving node reuses cached body geometry with a transient translation; incident wires and connection gestures use separate previews.
Placement or cancellation resumes spatial evaluation. Escape cancels without committing a model or layout change. Camera auto-pan during a gesture also defers queries for newly exposed cells until the gesture ends.
We also separated model revisions from presentation revisions. Moving a node changes layout; it should not trigger semantic revalidation or serialization of the entire ECS model. Dirty-state checks now cache model and presentation content independently.
Why this approach: pointer movement needs a cheap preview path, and releasing the pointer needs a cheap commit path. In the measured drag phase, only two visible cell meshes rebuilt after placement; affected hidden cells can remain dirty until needed.
Results: a usable overview, with tradeoffs visible
The archived native runs use the same acceptance workload and scripted interaction scenarios:
| Scenario | Original mean FPS | Optimized mean FPS | Original p95 frame | Optimized p95 frame |
| Whole-graph overview | 0.41 | 77.33 | 2,529.20 ms | 24.24 ms |
| Close-up | 50.43 | 87.45 | 23.32 ms | 30.26 ms |
| Pan | 46.90 | 88.96 | 24.56 ms | 30.99 ms |
| Zoom | 46.93 | 92.24 | 24.99 ms | 30.47 ms |
| Selection and focus | 44.93 | 92.79 | 25.61 ms | 25.30 ms |
| Node drag and placement | 50.02 | 91.57 | 21.96 ms | 29.14 ms |
In the overview, 1,000 entities became 170 visible clusters. Maximum submitted vertices fell from 629,635 to 25,900, about 24× fewer. Mean render conversion/submission time fell from 2,396.58 ms to 0.44 ms, avoiding the backend’s particularly expensive workload.
All six default-detail scenarios exceeded the 30 FPS mean target and stayed below 33.33 ms at p95. Higher averages did not improve every percentile: close-up, pan, zoom, and drag had worse p95 times in the final run. Occasional frames still exceeded the budget. The overview’s worst frame was 38.67 ms; the drag phase’s worst frame improved from 89.72 to 35.54 ms.
With merging disabled, the overview averaged 9.53 FPS, with a 110.69 ms p95. That is roughly 23× the original frame rate, but it is not a cache-only experiment: culling, batching, adaptive curves, and other changes all contribute.
Testing methodology
Measure the running editor
The native benchmark generates a semantically valid graph with 1,000 entities, 1,002 component instances, 3,000 attachments, three schemas, and 5,000 active connections: 4,000 slot links and 1,000 entity relationships. Nodes occupy a 40-column grid and include real attachment and pin content.
Each scenario runs 15 warmup frames and 60 measured frames, with vsync disabled. The scenarios cover the overview, close-up, pan, wheel zoom, selection/focus, and node dragging through placement. The optimized benchmark adds an explicit unmerged overview.
Frame timing includes event processing, UI construction, dirty-state checks, draw-data creation, SDL geometry submission, and presentation. Reported FPS is the reciprocal of mean end-to-end CPU wall frame time, including renderer backpressure. It is not a GPU timestamp measurement or a physical display refresh guarantee. Cold startup is reported separately, and screenshot capture costs are excluded from measured frames.
Both archived native runs were recorded on November 7, 2026, using an AppleClang 21 C++23 Release build on macOS 26.7.1: a six-core Intel Core i7 at 2.6 GHz with 32 GiB RAM. SDL selected Metal. The window was 1320 × 840 logical pixels, with a 2640 × 1680 render target. The machine has integrated Intel UHD 630 graphics and an AMD Radeon Pro 5500M with 4 GB VRAM. The benchmark report itself does not identify which Metal device SDL selected.
These are short runs on a shared workstation with other applications running. They establish the observed improvement on this configuration; Linux, Windows, and other hardware remain unmeasured.
Verify that the editor still does the work
The benchmark checks that pan and zoom change the viewport, that dragging commits an undoable position change, and that persistent content is restored exactly after undo and temporary setting changes. A fast report with a hidden canvas or failed drag does not pass.
Cache counters verify the intended behavior. Measured idle overview and close-up frames performed zero detail-level evaluations, zero visibility queries, and zero mesh builds. The scripted pan and zoom reused existing meshes. Selection/focus built newly exposed cells. Gesture checks verified that spatial evaluation remained frozen during dragging. These results describe the measured paths; unfamiliar regions can still cause cache misses.
Separate correctness tests cover crossing connections, local invalidation, detail-level hysteresis, merge cutoffs, metadata preservation, hidden-cache retention, and equivalence between incremental updates and fresh rebuilds. ImGui interaction tests exercise node and connection gestures, cancellation, cluster navigation, and undo. Native captures check the rendered views, separately from performance measurement.
What comes next
The current caches store CPU geometry. SDL and ImGui still copy, transform, and upload that geometry each frame; we have not implemented persistent GPU vertex buffers. Worker jobs still join before the frame continues. Actual ECS edits still trigger full semantic validation and graph projection refresh. The unmerged overview remains expensive.
Those are the next profiling targets. The gain so far comes from a more useful rendering contract: reuse unchanged work, rebuild affected dependencies, and show the level of detail the current view can support.