Move a large graph instead of drawing it again (#292)
game-maker / gate (push) Successful in 2m44s
game-maker / suite (push) Successful in 13m55s

In a graph of many nodes the frame rate dropped while panning, and
more so while one node was dragged. Every pointer move rebuilt every
box, every text and every curve, and a drag also announced a changed
project, so the panels rebuilt along with it.

- Panning and zooming move the picture. GraphCanvas.applyView sets
  position and scale and draws anew only the grid and the rubber
  band. It used to call redraw().
- A node draws its box and makes its texts only when what they show
  has changed. NodeView keeps a signature of what redraw reads and
  one of what rebuildTexts reads, and returns early while they hold.
- 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). state.preview() waits for the
  command of the release.
- Nodes outside the picture are not drawn. inView in graphView.ts is
  the rule, cull() applies it after every redraw and every change of
  the view.
- The graph is a render group, and box and pins go into the batch.
  The mask sits on a container above the group, on the group itself
  it would be panned and zoomed along.
- The rubber band and the drop mark of a dragged behavior are drawn
  on their own (drawBand), without a redraw of the graph.

Measured in the CI image with 388 nodes and 540 links, fitted to the
canvas, 240 pointer moves 16 ms apart:

- Pan, script per move: 25.2 ms before, 2.0 ms after
- Pan: 4.4 frames per second before, 18.7 after
- Drag a node: 2.7 frames per second before, 22.4 after
- The still picture: 9 frames per second before, 21 after

Not solved: the still picture of such a graph stays at 21 frames per
second in the test browser. What is left is the drawing itself, and
the frame rates are those of that browser, not of a desktop GPU.

Tests:
- Gate on melektaus-atx: 207 files, 3121 tests (3114 + 7), exit 0
- e2e 4,6,18,19,20,23,24,26,28,31,36,43,51,53 on melektaus-atx: 542
  pass, 1 fail. The fail is module 51 after module 43, an order the
  suite does not have, and it fails the same way without this
  change. Module 51 alone: 12 checks, all pass.
- No full run and no smokes. The sum of 1660 (1656 + 4) is reckoned,
  and the CI workflow is switched off.
- Seven unit tests: six for inView in tests/editor/graph-view.test.ts
  and one in frame-preview.test.ts
- Four e2e checks in block 6z-xii of module 06: pan, drag, zoom in
  and fit, a changed value
- Seven browser counter-probes fail 2 to 3 checks each

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-02 10:18:20 +02:00
1 parent 32a0af6e2c
commit 6d79cf59a3
11 files changed
+393 -23

No files matched your search

+59 -1
View File
@@ -131,6 +131,15 @@ export class NodeView extends Widget {
private readonly valueTexts = new Map<string, Text>();
/** Where an output's value may be drawn: right-aligned before its label, clear of the input text. */
private valueSlots = new Map<string, { left: number; right: number; top: number }>();
/**
* What the texts and the box were last made from. A graph is redrawn on every model event and on
* every frame of a run, and for nearly every node nothing has changed: a text made anew is a
* picture uploaded anew, and a box cleared is a shape built anew (#292).
*/
private textsOf = '';
private drawnOf = '';
/** How often the texts and the box were really made, for the checks. */
readonly made = { texts: 0, box: 0 };
/**
* Not fixed at construction: a nested-behavior node's ports come from another graph, so the
@@ -145,6 +154,10 @@ export class NodeView extends Widget {
super();
this.def = def;
this.cursor = 'grab';
// Pixi draws a shape of more than 200 vertices on its own, and the round pins are: a few hundred
// nodes were a few hundred draw calls. In the batch they share one.
this.box.context.batchMode = 'batch';
this.ports.context.batchMode = 'batch';
this.addChild(this.box, this.ports);
this.sync(node, { nodes: [node], edges: [] }, null, def);
}
@@ -215,10 +228,51 @@ export class NodeView extends Widget {
const height = this.collapsed ? TITLE_HEIGHT : this.headerHeight() + this.rowCount() * ROW_HEIGHT + PAD;
// Sized before the texts: output labels are placed against the right edge.
this.resize(this.collapsed ? this.collapsedWidth() : NODE_WIDTH, height);
this.rebuildTexts();
const texts = this.textSignature();
if (texts !== this.textsOf) {
this.textsOf = texts;
this.made.texts += 1;
this.rebuildTexts();
}
this.position.set(node.x, node.y);
}
/** Everything `rebuildTexts` reads. */
private textSignature(): string {
const rows: unknown[] = [];
for (let i = 0; i < this.rowCount(); i++) {
const input = this.def.inputs[i];
const output = this.def.outputs[i];
rows.push(
input ? [this.inputText(input), rowStyle(input)] : null,
output ? [output.label, rowStyle(output), output.type !== 'exec' && !output.ghost, output.key] : null,
);
}
return JSON.stringify([this.w, this.collapsed, this.def.label, this.paramLines, this.problem, rows]);
}
/** Everything `redraw` reads. */
private drawSignature(): string {
const port = (p: PortDef) => `${p.key}:${p.paintType ?? p.type}:${p.ghost ? 1 : 0}`;
return JSON.stringify([
this.w,
this.h,
this.collapsed,
this.def.category,
this.def.transitionEdges === true,
this.isStart,
this.hot,
this.selected,
this.problem !== null,
this.stateMark,
this.linked,
this.targetPort,
this.headerHeight(),
this.def.inputs.map(port),
this.def.outputs.map(port),
]);
}
private collapsedWidth(): number {
const title = CanvasTextMetrics.measureText(this.def.label, textStyle('accent')).width;
return collapsedNodeWidth(title, TITLE_X + COLLAPSED_TITLE_END, NODE_WIDTH);
@@ -408,6 +462,10 @@ export class NodeView extends Widget {
}
override redraw(): void {
const drawn = this.drawSignature();
if (drawn === this.drawnOf) return;
this.drawnOf = drawn;
this.made.box += 1;
const c = Theme.colors;
this.box.clear();
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });