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:
1 parent
5fbc6eb088
commit
a7e793f6de
4 files changed
+187
-16
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user