Keep many spawned behaviors from eating the frame

A scene with 200 spawned gold pickups, each with a Pickup behavior of
97 nodes, dropped the game to about 14 fps. The graphs themselves ran
only a dozen nodes per pickup and frame. The cost was the lookup: for
every data input the interpreter searched all edges and all nodes of
the graph linearly and computed the port shape of the node again.

- New module core/graph/graphIndex.ts builds tables once per graph:
  nodes by id, edges by node and port, node types. Port shapes are
  cached as well.
- BehaviorSystem shares one cache between all its instances and
  clears it in syncFromModel. A graph changed during a run applies
  from the next frame on, as before.
- An interpreter without a cache, as in most tests, searches as
  before.

Measured on the scene Performance test (headless per frame, browser
fps on a throwaway copy of the project):

- Headless: about 21 ms down to about 3.6 ms per frame.
- Browser, about 505 entities: 13.6 fps up to 23 fps.
- Browser, about 320 entities: 32.3 fps up to 48 fps.
- After the fix behaviors take about 15 % of the browser profile.
  The rest is the editor mirroring the game into the model and
  rebuilding its panels, which is a separate change.

Tests:

- ./check.sh: 192 files, 2786 tests, CHECK EXIT 0.
- Full e2e run (local): ALL PASSED, 1551 checks.
- tests/core/graph-index.test.ts: 2 tests. The tables return the
  same as the search, in the same edge order. A graph changed during
  a run applies from the next frame.
- Counter-check: without clearing the cache in syncFromModel the
  second test fails with ['before'].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 10:36:10 +02:00
1 parent 5fbc6eb088
commit a7e793f6de
4 files changed
+187 -16

No files matched your search

+23 -15
View File
@@ -1,7 +1,8 @@
import { blockProblem, findMode, MATCH_INPUT, matchOf, searchOutcome, stopsAtHit } from './findBlock';
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
import type { Graph, GraphNode } from './Graph';
import { type GraphCache, type GraphIndex, searchingIndex } from './graphIndex';
import { type GraphLookup, NO_LOOKUP } from './lookup';
import { BEHAVIOR_END, callEntries, runDef, terminalNode } from './nested';
import { BEHAVIOR_END, callEntries, terminalNode } from './nested';
import { fieldValue, splitField, withSplitValues } from './portSplits';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import type { NodeContext } from './NodeContext';
@@ -37,14 +38,11 @@ export interface TraceListener {
/** One graph's pulse state. A nested behavior runs in its own – node ids are unique per graph only. */
interface Frame {
graph: Graph;
index: GraphIndex;
outputCache: Map<string, Record<string, Value>>;
dataCache: Map<string, Record<string, Value>>;
}
function emptyFrame(graph: Graph): Frame {
return { graph, outputCache: new Map(), dataCache: new Map() };
}
/**
* The variables a call starts with: today's declarations at their defaults, except that a kept one
* resumes at the value the last call from the same site left – unless that value is gone or no
@@ -89,12 +87,22 @@ export class GraphInterpreter {
constructor(
readonly graph: Graph,
private readonly lookup: GraphLookup = NO_LOOKUP,
/** Without one the graph is searched every time, so it may change between two pulses. */
private readonly cache: GraphCache | null = null,
) {
this.frame = emptyFrame(graph);
this.frame = this.emptyFrame(graph);
}
eventNodes(type: string): GraphNode[] {
return this.graph.nodes.filter((n) => n.type === type);
private indexOf(graph: Graph): GraphIndex {
return this.cache ? this.cache.indexOf(graph, this.lookup) : searchingIndex(graph, this.lookup);
}
private emptyFrame(graph: Graph): Frame {
return { graph, index: this.indexOf(graph), outputCache: new Map(), dataCache: new Map() };
}
eventNodes(type: string): readonly GraphNode[] {
return this.indexOf(this.graph).ofType(type);
}
fire(
@@ -116,7 +124,7 @@ export class GraphInterpreter {
/** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */
pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void {
this.frame = emptyFrame(this.graph);
this.frame = this.emptyFrame(this.graph);
this.callStack = [];
this.callSites = [];
this.steps = 0;
@@ -139,7 +147,7 @@ export class GraphInterpreter {
}
private follow(node: GraphNode, port: string, ctx: NodeContext, depth: number): void {
for (const edge of edgesOutOf(this.frame.graph, node.id, port)) {
for (const edge of this.frame.index.outOf(node.id, port)) {
const target = this.node(edge.to.node);
if (!target) continue;
this.trace?.onEdge(edge.id);
@@ -185,7 +193,7 @@ export class GraphInterpreter {
const variables = resumedVariables(program.defaults, this.keptCalls.get(sites), kept);
if (kept.length > 0) this.keptCalls.set(sites, variables);
const outer = this.frame;
this.frame = emptyFrame(program.graph);
this.frame = this.emptyFrame(program.graph);
for (const entry of entries) {
this.frame.outputCache.set(entry.id, { ...call.inputs });
this.record(entry.id, call.inputs);
@@ -296,7 +304,7 @@ export class GraphInterpreter {
}
private inputValue(node: GraphNode, port: PortDef, ctx: NodeContext): Value {
const edge = edgesInto(this.frame.graph, node.id, port.key)[0];
const edge = this.frame.index.into(node.id, port.key)[0];
if (!edge) return inlineValue(node, port);
const source = this.node(edge.from.node);
const split = source && splitField(source, edge.from.port);
@@ -352,10 +360,10 @@ export class GraphInterpreter {
/** Without split field ports: a node produces its wholes, and `inputValue` picks the fields. */
private def(node: GraphNode) {
return runDef(node, this.frame.graph, this.lookup);
return this.frame.index.def(node);
}
private node(id: string): GraphNode | undefined {
return this.frame.graph.nodes.find((n) => n.id === id);
return this.frame.index.node(id);
}
}