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

+5 -1
View File
@@ -23,6 +23,7 @@ import {
type TraceListener,
type Value,
} from '../../core/graph';
import { GraphCache } from '../../core/graph/graphIndex';
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
import { keyName } from '../../core/keys';
import { graphLookup } from '../../core/model/graphLookup';
@@ -194,6 +195,8 @@ export class BehaviorSystem implements System {
private unranked = true;
/** The declarations as last read; an edit is what differs from these (see `syncDeclarations`). */
private declared: Record<string, PropertyBag> = {};
/** Shared by every instance; an edit to the project empties it (see `syncFromModel`). */
private readonly graphs = new GraphCache();
private readonly collisions: CollisionSource | null;
private readonly animations: AnimationSystem | null;
private readonly audio: AudioSink;
@@ -231,6 +234,7 @@ export class BehaviorSystem implements System {
/** An edit to the project while the run is on: declarations and the hierarchy may have changed. */
syncFromModel(): void {
this.graphs.clear();
this.syncDeclarations();
this.unranked = true;
}
@@ -323,7 +327,7 @@ export class BehaviorSystem implements System {
if (!asset) continue;
const trace = { edges: new Map<string, number>(), nodes: new Map<string, number>() };
const values = new Map<Graph, Map<string, Produced>>();
const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project));
const interpreter = new GraphInterpreter(asset.graph, graphLookup(this.project), this.graphs);
interpreter.trace = {
onEdge: (id) => trace.edges.set(id, world.time),
onNode: (id) => trace.nodes.set(id, world.time),