From 4ff5495274e8dbfa46d27962c7dd27def134f8b8 Mon Sep 17 00:00:00 2001 From: meleki Date: Fri, 2 Oct 2026 10:18:25 +0200 Subject: [PATCH] Architecture, Development: a large graph is moved, not drawn again (#292) --- Architecture.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++--- Development.md | 2 +- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Architecture.md b/Architecture.md index 76ffb05..f2e03f5 100644 --- a/Architecture.md +++ b/Architecture.md @@ -260,7 +260,7 @@ src/editor/graph/ GraphEditor (header, breadcrumb), GraphCanvas and what it `GraphCanvasHost`: wiring (making edges — a drag from a pin, the palette a drop into empty space opens, a transition armed from a state's menu), graphDrags (nodes, groups and comments moved or resized, each gesture one undo step), annotationLayer (the boxes behind - the edges); graphView (zoom limits, grid, panAndZoom — shared with NodeBoard and the Tile tab's TileCanvas), EdgeLayer (curves and fold marks), graphMenus, + the edges); graphView (zoom limits, grid, panAndZoom — shared with NodeBoard and the Tile tab's TileCanvas — and `inView`, which nodes are in the picture, #292), EdgeLayer (curves and fold marks), graphMenus, NodeView, NodePalette + paletteEntries (which also ranks a search), folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses and the clock that launches them), AnnotationView + annotationDrag (groups and comments: what a drag carries, each thing moved once), SnapToggle (the magnet every tab that snaps carries, the vector art tab included, #194) @@ -356,8 +356,9 @@ gesture runs, its own copy of the rings is the truth and it never reads the mode Every other drag goes through `editor/framePreview.ts` (#213): `FramePreview.request()` in the move handler, `cancel()` on release, one instance per gesture owner. The model is still written on every pointer move, only `state.preview()` waits for the frame. The Scene view (moving objects, -polygon corners, tile strokes), the Texture tab (frames, slice edges, collision corners) and the -graph canvases (nodes, groups, comments) use it. Measured with 120 extra objects in the scene and +polygon corners, tile strokes) and the Texture tab (frames, slice edges, collision corners) use +it; the graph canvases did until #292 and now follow a drag without a project event at all, see +below. Measured with 120 extra objects in the scene and 120 pointer moves 8 ms apart: 26, 34 and 29 ms per move before, 18, 18 and 17 after (the probe's own pause and round trip are in every number). The Texture tab had been paying twice: Pixi repeats the last pointer move a few milliseconds later to update hover, with the same native event, and @@ -371,6 +372,54 @@ stop, at 47 ms per move. Now it is 51 to 61 events and 22 ms per move. The Pixel panel (the colour of a palette swatch, the opacity of a layer) and the Curve tab's `t` and `v` fields follow the same rule; those were changed without a measurement of their own. +**A graph of many nodes is moved, not drawn again** (#292). Five rules keep a graph of a few +hundred nodes quick to pan, zoom and drag in, all in `editor/graph/`: + +- *Panning and zooming move the picture.* `GraphCanvas.applyView` sets the position and scale of + the graph and draws anew only what is laid out on the screen: the grid and the rubber band. It + used to call `redraw()`, which rebuilt every box and every curve on every pointer move. +- *A node draws its box and makes its texts only when what they show has changed.* `NodeView` + keeps a signature of everything `redraw` reads and one of everything `rebuildTexts` reads, and + returns early while they hold. A model event still asks every node (`GraphCanvas.sync`), and a + run still calls `redraw()` every frame, but a text made anew is a picture uploaded anew, and that + was the cost: one node dragged remade around 1500 texts per frame in a graph of 388 nodes. +- *A drag does not announce a changed project.* `GraphDrags` writes the model on every pointer move + and asks the canvas once a frame to follow (`GraphCanvasHost.moved`): the views are put where + their nodes are and the links drawn again. Where a node stands concerns no panel, no run and no + picture, so `state.preview()` is left out until the command of the release. +- *Nodes outside the picture are not drawn.* `inView` in `graphView.ts` is the rule, `cull()` sets + `visible` after every redraw and every change of the view. At a working zoom that is most of a + large graph. +- *The graph is a render group, and its shapes go into the batch.* `canvas.isRenderGroup` lets Pixi + move the whole graph as one and keeps a node entering the picture from rebuilding the drawing of + the whole editor. The mask sits on a container **above** the group: on the group itself it is + panned and zoomed along with the graph, which cut the picture down to a small window. Pixi draws + a `Graphics` of more than 200 vertices on its own, and the round pins of a node are that, so + box and pins set `context.batchMode = 'batch'`: 474 draw calls a frame became 160. + +Measured in the CI image with a behavior of 97 nodes and 135 links taken from a real project, and +with four copies of it (388 nodes, 540 links), 240 pointer moves 16 ms apart, fitted to the canvas: + +| | before | after | +|---|---|---| +| pan, 97 nodes: script per move | 6.7 ms | 1.9 ms | +| pan, 388 nodes: script per move | 25.2 ms | 2.0 ms | +| pan, 388 nodes: frames per second | 4.4 | 18.7 | +| drag a node, 97 nodes: frames per second | 4.4 | 33.4 | +| drag a node, 388 nodes: frames per second | 2.7 | 22.4 | +| the still picture, 388 nodes: frames per second | 9 | 21 | + +Zoomed in to 1 the same gestures run at 59 frames a second with 97 nodes and at 29 to 38 with 388. +The frame rates are those of the test browser, whose GPU sits behind WSL's d3d12 driver and pays +for every draw call: with the graph hidden altogether the Behavior tab draws at 37 frames a second +there, because the panels around it cost 148 draw calls of their own, one pair per mask. The script +times are the part that carries over to another machine. + +What a check can see of this: `__gm.graph.made()` counts how often texts, boxes and links were +really made, and `__gm.graph.nodesDrawn()` how many nodes are left in the picture. A probe that +wants the frame rate of a picture nobody changes has to ask for every frame itself (`__gm.wake()` +in a `requestAnimationFrame` loop): the editor draws on demand. + The Texture tab draws its frames in two layers (#220). `sheet` holds every frame but the selected one with its outlines, `overlay` the selected frame, its handles and a frame being spanned. Both are drawn without the pan, in a container that sits at the pan. A pan only moves that container, diff --git a/Development.md b/Development.md index 31fd74b..b6214df 100644 --- a/Development.md +++ b/Development.md @@ -18,7 +18,7 @@ php tests/php/accounts.test.php # the API's accoun # has and the web server's container has not (SKIP there) php tests/php/snapshots.test.php # the snapshot store (#231), the same way php tests/php/releases.test.php # the release store (#266), the same way -node tools/seed-e2e.mjs && node tools/e2e.mjs # 1656 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 1660 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result