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

+25
View File
@@ -220,6 +220,31 @@ describe('placing a container', () => {
expect(f.sceneOf().objects).toHaveLength(1);
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', () => {
+100 -1
View File
@@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RemoveSceneObjectsCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../src/core/commands/sceneObjectCommands';
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 { 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]);
});
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', () => {
const f = setup();
const { sceneId, spriteId, a, sceneOf } = f;