Stop resolving every object in every frame of a run (#287)

World.syncToModel runs after every frame of a run and resolved each
object of the scene anew to compare it with its entity. With 222
entities that was most of the Editor line in the performance panel,
also for entities that are never drawn.

- The world keeps per entity what the model said when it last read
  the object (syncFromModel, spawn) or wrote it. A frame compares the
  entity with that and resolves nothing while both agree.
- A place that differs is written as three numbers.
- A value that differs is written as an override, and that one object
  is resolved again. An override can bring rows of its own, such as a
  sprite's shader.
- The objects of entities that left the run are dropped only in a
  frame in which one left. World.remove notes that.
- Editing during a run works as before: the editor has the model read
  again on every project event, which renews what the world keeps.

One difference: a write to the model that goes past the editor and
does not have it read again now stays. The next frame used to
overwrite it with the entity's state. No code does that, during a run
only the world writes.

Measured on stein2 with a copy of the reference project of #284, ms
per frame: syncToModel alone 0.92 to 0.12 (Node, 600 frames), the
Editor line in the browser 1.14 / 1.20 / 1.29 to 0.25 / 0.25 / 0.27.
Physics and Drawing are unchanged.

Tests:
- Gate: 198 files, 2891 tests (2887 + 4), exit 0
- e2e, full run: 1613 checks passed, exit 0. That measures the total
  that was only computed since #273.
- Smokes: 24 / 19 / 21, exit 0
- Four new unit tests, no new e2e check: 60 unchanged frames resolve
  nothing, an edit during the run arrives and stays, an override with
  rows of its own is seen with them, container content in an editor
  run is written into the instance edits once
- Seven counter-probes fail, 1 to 4 tests each

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-01 10:27:54 +02:00
1 parent cfd288c4b7
commit 26cc6324af
3 files changed
+212 -37

No files matched your search

+87 -36
View File
@@ -2,10 +2,10 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from
import { newId } from '../core/ids'; import { newId } from '../core/ids';
import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset'; import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset';
import { sameAtRest, withoutObjects } from '../core/graph/liveObjects'; import { sameAtRest, withoutObjects } from '../core/graph/liveObjects';
import { bagWithoutItemTypes, type PropertyBag, type Vec2 } from '../core/model/Properties'; import { bagWithoutItemTypes, type PropertyBag, setBagValue, type Vec2 } from '../core/model/Properties';
import { isPlaceableType } from '../core/model/AssetTypes'; import { isPlaceableType } from '../core/model/AssetTypes';
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
import { groupNamed, treeOrder } from '../core/model/hierarchy'; import { groupNamed, type ObjectTree, treeOrder } from '../core/model/hierarchy';
import { liveTileAt, withTile } from '../core/model/liveTilemap'; import { liveTileAt, withTile } from '../core/model/liveTilemap';
import { referenceCandidates } from '../core/model/objectRefs'; import { referenceCandidates } from '../core/model/objectRefs';
import { type Cell, presentTile } from '../core/model/tilemap'; import { type Cell, presentTile } from '../core/model/tilemap';
@@ -47,6 +47,36 @@ export interface ModelWrite {
values: boolean; values: boolean;
} }
/**
* What the model said of an entity when the world last read it or wrote it. `syncToModel` runs every
* frame and compares against this instead of resolving every object anew (#287), which holds as long
* as whoever else changes the model has it read again (`syncFromModel`).
*/
interface ModelState {
asset: Asset;
x: number;
y: number;
z: number;
props: PropertyBag;
}
const NO_KEYS: readonly string[] = [];
/** The keys whose value in the run no longer says what the model does. */
function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] {
let keys: string[] | undefined;
for (const key of Object.keys(live)) {
const value = live[key];
const atRest = model.props[key];
if (atRest === value || !(key in model.props)) continue;
// What a run holds of an object stays in the run; the scene gets the empty one.
// A private variable (#257) has no override to hold its value: that stays in the run.
if (isPrivateVariable(model.asset, key) || sameAtRest(atRest, value)) continue;
(keys ??= []).push(key);
}
return keys ?? NO_KEYS;
}
/** /**
* The running game: entities of one scene plus the systems ticking them. Built on the play copy * The running game: entities of one scene plus the systems ticking them. Built on the play copy
* (discarded on stop) or, for the editor run, on the saved model, where its writes stay. * (discarded on stop) or, for the editor run, on the saved model, where its writes stay.
@@ -103,6 +133,9 @@ export class World {
private structureChanged = false; private structureChanged = false;
/** Children a Destroy moved up to their grandparent; written into the scene by `syncToModel`. */ /** Children a Destroy moved up to their grandparent; written into the scene by `syncToModel`. */
private readonly movedUp = new Set<Entity>(); private readonly movedUp = new Set<Entity>();
/** Set when an entity left the run; the next `syncToModel` drops its object. */
private entitiesGone = false;
private readonly modelStates = new WeakMap<Entity, ModelState>();
private tilesChanged = false; private tilesChanged = false;
private constructor( private constructor(
@@ -257,7 +290,8 @@ export class World {
entity.x = object.position.x; entity.x = object.position.x;
entity.y = object.position.y; entity.y = object.position.y;
entity.z = object.position.z; entity.z = object.position.z;
const resolved = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object)); const resolved = this.modelProps(asset, object);
this.modelStates.set(entity, { asset, ...object.position, props: resolved });
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
// The scene only ever got the objects' empty places, so a value that differs by them alone stays. // The scene only ever got the objects' empty places, so a value that differs by them alone stays.
// A private value never reaches the scene, so the scene's is only where it starts. // A private value never reaches the scene, so the scene's is only where it starts.
@@ -268,6 +302,11 @@ export class World {
return entity; return entity;
} }
/** An object's values as a run holds them. */
private modelProps(asset: Asset, object: SceneObject): PropertyBag {
return bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
}
/** /**
* Adds the object to the scene and creates only its entity, leaving the others alone (they may have * Adds the object to the scene and creates only its entity, leaving the others alone (they may have
* moved this tick already). Position is local to `parent`, world when there is none. * moved this tick already). Position is local to `parent`, world when there is none.
@@ -318,10 +357,47 @@ export class World {
} }
const reparented = this.movedUp.size > 0; const reparented = this.movedUp.size > 0;
this.movedUp.clear(); this.movedUp.clear();
// Looked up once for the whole pass: asking the list per object made this grow with the square const dropped = this.entitiesGone && this.dropGone(scene);
// of the entities, every frame of a run (#284). The first of an id counts, as `findByObjectId` has it. this.entitiesGone = false;
const alive = new Map<string, Entity>(); for (const entity of this.entities) {
for (const e of this.entities) if (!alive.has(e.objectId)) alive.set(e.objectId, e); // A dragged object's place belongs to the drag, which reaches the entity on the next re-read.
if (this.held.has(entity.objectId)) continue;
const model = this.modelStates.get(entity);
if (!model) continue;
const moved = model.x !== entity.x || model.y !== entity.y || model.z !== entity.z;
const drifted = driftedKeys(entity.props, model);
if (!moved && drifted.length === 0) continue;
// Only now, because a ref for resolved content costs a resolve of its own – and because a
// direct write to such an object would land in the freshly built copy and be lost.
const ref = objectRef(this.project, scene, entity.objectId);
if (!ref) continue;
if (moved) {
ref.setPosition({ x: entity.x, y: entity.y, z: entity.z });
model.x = entity.x;
model.y = entity.y;
model.z = entity.z;
}
if (drifted.length > 0) {
// Read again rather than patched: an override may bring rows of its own (a sprite's shader).
// A container's content is a copy made before the write, so the written values go on top.
const overrides = { ...ref.object.overrides };
for (const key of drifted) {
const value = withoutObjects(entity.props[key]);
ref.setOverride(key, value);
setBagValue(overrides, key, value);
}
model.props = this.modelProps(model.asset, { ...ref.object, overrides });
}
values = true;
}
return { structure: spawned || reparented || dropped, values };
}
/** Drops the objects whose entity left the run; true when there was one. */
private dropGone(scene: ObjectTree): boolean {
// Looked up once for the whole pass: asking the list per object grows with the square of the
// entities (#284). The first of an id counts, as `findByObjectId` has it.
const alive = new Set(this.entities.map((e) => e.objectId));
const assets = new Map<string, Asset>(); const assets = new Map<string, Asset>();
for (const asset of this.project.assets) if (!assets.has(asset.id)) assets.set(asset.id, asset); for (const asset of this.project.assets) if (!assets.has(asset.id)) assets.set(asset.id, asset);
const before = scene.objects.length; const before = scene.objects.length;
@@ -330,34 +406,7 @@ export class World {
const asset = assets.get(object.assetId); const asset = assets.get(object.assetId);
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1); if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
} }
for (const object of resolveObjects(this.project, scene)) { return scene.objects.length !== before;
// A dragged object's place belongs to the drag, which reaches the entity on the next re-read.
if (this.held.has(object.id)) continue;
const entity = alive.get(object.id);
const asset = entity && assets.get(entity.assetId);
if (!entity || !asset) continue;
const p = object.position;
const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
// What a run holds of an object stays in the run; the scene gets the empty one.
// A private variable (#257) has no override to hold its value: that stays in the run.
const drifted = Object.keys(entity.props).filter(
(k) =>
k in base &&
!isPrivateVariable(asset, k) &&
base[k] !== entity.props[k] &&
!sameAtRest(base[k], entity.props[k]),
);
if (!moved && drifted.length === 0) continue;
// Only now, because a ref for resolved content costs a resolve of its own – and because a
// direct write to such an object would land in the freshly built copy and be lost.
const ref = objectRef(this.project, scene, object.id);
if (!ref) continue;
if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z });
for (const key of drifted) ref.setOverride(key, withoutObjects(entity.props[key]));
values = true;
}
return { structure: spawned || reparented || scene.objects.length !== before, values };
} }
/** Registers a system; it starts immediately and is ticked in registration order. */ /** Registers a system; it starts immediately and is ticked in registration order. */
@@ -520,7 +569,9 @@ export class World {
remove(entity: Entity): void { remove(entity: Entity): void {
for (const child of this.childrenOf(entity)) this.remove(child); for (const child of this.childrenOf(entity)) this.remove(child);
const index = this.entities.indexOf(entity); const index = this.entities.indexOf(entity);
if (index >= 0) this.entities.splice(index, 1); if (index < 0) return;
this.entities.splice(index, 1);
this.entitiesGone = true;
} }
/** /**
+25
View File
@@ -220,6 +220,31 @@ describe('placing a container', () => {
expect(f.sceneOf().objects).toHaveLength(1); expect(f.sceneOf().objects).toHaveLength(1);
expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined(); expect(f.sceneOf().objects[0]!.instanceEdits[f.tA]).toBeDefined();
}); });
it('writes what a run changes of unbaked content into the instance edits, once (#287)', () => {
const f = templateSetup();
const world = World.instantiate(f.project, f.sceneId, { bakeContainers: false });
const instance = () => f.sceneOf().objects[0]!;
const content = world.findByObjectId(f.child(f.tA))!;
const home = { x: content.x, y: content.y };
content.x = home.x + 7;
content.props.width = 12;
expect(world.syncToModel()).toEqual({ structure: false, values: true });
expect(instance().instanceEdits[f.tA]).toMatchObject({
position: { x: home.x + 7 },
overrides: { width: 12 },
});
expect(world.syncToModel()).toEqual({ structure: false, values: false });
// Back at the template's place the edit of the place is gone, and the next frame has nothing to say.
content.x = home.x;
expect(world.syncToModel().values).toBe(true);
expect(instance().instanceEdits[f.tA]).toEqual({ position: {}, overrides: { width: 12 } });
expect(world.syncToModel().values).toBe(false);
world.syncFromModel();
expect({ x: content.x, y: content.y, width: content.props.width }).toEqual({ ...home, width: 12 });
});
}); });
describe('editing a container template', () => { describe('editing a container template', () => {
+100 -1
View File
@@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { import {
ClearOverrideCommand, ClearOverrideCommand,
MoveSceneObjectCommand,
RemoveSceneObjectsCommand, RemoveSceneObjectsCommand,
SetOverrideCommand, SetOverrideCommand,
SetParentCommand, SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands'; } from '../../src/core/commands/sceneObjectCommands';
import { worldMatrixOf } from '../../src/core/graph'; import { worldMatrixOf } from '../../src/core/graph';
import { findScene } from '../../src/core/model/Project'; import { findAsset, findScene } from '../../src/core/model/Project';
import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders';
import { World } from '../../src/runtime/World'; import { World } from '../../src/runtime/World';
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
@@ -81,6 +84,102 @@ describe('world ↔ model synchronisation (editing while playing)', () => {
expect(sceneOf().objects.map((o) => o.id)).toEqual([a]); expect(sceneOf().objects.map((o) => o.id)).toEqual([a]);
}); });
it('syncToModel asks the model again only for an object whose values it wrote (#287)', () => {
const f = setup();
const { project, spriteId, a, b, object } = f;
const { world } = startWorld(f);
// Every resolve of a placed box reads its asset's values, so the reads count the resolves.
const sprite = findAsset(project, spriteId)!;
let reads = 0;
sprite.props = new Proxy(sprite.props, {
get(bag, key, receiver) {
reads++;
return Reflect.get(bag, key, receiver) as unknown;
},
});
for (let frame = 0; frame < 60; frame++)
expect(world.syncToModel()).toEqual({ structure: false, values: false });
expect(reads).toBe(0);
// A place is three numbers: written, and nothing to resolve.
world.findByObjectId(b)!.x = 310;
expect(world.syncToModel().values).toBe(true);
expect(object(b).position.x).toBe(310);
expect(reads).toBe(0);
expect(world.syncToModel().values).toBe(false);
// A value is written as an override, and that one object is read again – once.
world.findByObjectId(a)!.props.width = 999;
expect(world.syncToModel().values).toBe(true);
expect(object(a).overrides).toEqual({ width: 999 });
const afterWrite = reads;
expect(afterWrite).toBeGreaterThan(0);
expect(world.syncToModel()).toEqual({ structure: false, values: false });
expect(reads).toBe(afterWrite);
});
it('an edit made during the run arrives and is not overwritten by an older picture (#287)', () => {
const f = setup();
const { stack, sceneId, spriteId, a, b, object } = f;
const { world } = startWorld(f);
const entityA = world.findByObjectId(a)!;
const entityB = world.findByObjectId(b)!;
entityA.props.width = 999;
world.syncToModel();
// The editor has the model read again after every command, as `Editor` does on a project event.
const edit = (command: Parameters<typeof stack.execute>[0]) => {
stack.execute(command);
world.syncFromModel();
};
edit(new SetOverrideCommand(sceneId, a, 'width', 40));
expect(entityA.props.width).toBe(40);
expect(world.syncToModel()).toEqual({ structure: false, values: false });
expect(object(a).overrides).toEqual({ width: 40 });
edit(new SetAssetPropertyCommand(spriteId, 'height', 70));
expect(entityB.props.height).toBe(70);
expect(world.syncToModel()).toEqual({ structure: false, values: false });
expect(object(b).overrides).toEqual({});
edit(new MoveSceneObjectCommand(sceneId, b, { x: 5, y: 6, z: 0 }));
expect(world.syncToModel().values).toBe(false);
expect(object(b).position).toEqual({ x: 5, y: 6, z: 0 });
// What the run changes afterwards goes on top of the edit, key by key.
entityA.props.height = 11;
expect(world.syncToModel().values).toBe(true);
expect(object(a).overrides).toEqual({ width: 40, height: 11 });
// An object placed during the run is mirrored like the others.
const c = place(f, spriteId, { x: 50, y: 50 });
world.syncFromModel();
world.findByObjectId(c)!.y = 77;
expect(world.syncToModel().values).toBe(true);
expect(object(c).position.y).toBe(77);
});
it('an override that brings rows of its own is seen with them from the next frame on (#287)', () => {
const f = makeSpriteScene();
const text = new CreateAssetCommand('text', 'Title');
const shader = new CreateAssetCommand('shader', 'Glow');
f.stack.execute(text);
f.stack.execute(shader);
const declared = { name: 'glow', default: 3, min: 0, max: 10 };
f.stack.execute(new AddNodeCommand(shader.asset.id, SHADER_PROPERTY, 0, 0, declared));
const id = place(f, text.asset.id, { x: 0, y: 0 });
const { world } = startWorld(f);
const entity = world.findByObjectId(id)!;
entity.props[SHADER_KEY] = shader.asset.id;
world.syncToModel();
expect(f.object(id).overrides).toEqual({ [SHADER_KEY]: shader.asset.id });
// The shader's row is the object's now, so a value the run sets for it is one to write.
entity.props.glow = 8;
expect(world.syncToModel().values).toBe(true);
expect(f.object(id).overrides.glow).toBe(8);
});
it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => { it('spawn adds an object to the runtime scene and an entity at once, without resetting others', () => {
const f = setup(); const f = setup();
const { sceneId, spriteId, a, sceneOf } = f; const { sceneId, spriteId, a, sceneOf } = f;