Should have seen this coming - but Effects can result in race conditions as other systems + effects operate on the same entities.
For example, an effect:
const addMeshEffect = makeEffect([Model], async (ent, game) => {
const scene = game.globals.immediate('scene');
const mesh = new Mesh();
scene.add(mesh);
game.add(ent.id, Mesh, { mesh });
await doLongTask();
return () => {
scene.remove(mesh);
game.remove(ent.id, Mesh);
};
});
const recycleModelsEffect = makeEffect([Model], (ent, game) => {
if (someCondition()) {
game.remove(ent.id, Model);
}
});
That's not even the worst that could happen (what if scene
got replaced between await
s?) but demonstrates how Model
could be removed, but the actual mesh could remain in the scene for multiple frames because the cleanup is blocked behind a long-running task.
I'm thinking perhaps the cleanup and setup should be separated so that cleanup can run immediately without waiting for an async setup. But that doesn't resolve the setup continuing to operate, potentially on invalid resources.
Cancelation could be required here. Obviously async
functions are notoriously obnoxious to cancel. I don't want to force the user to check a canceled
flag every step.
Generators are often looked to as the antidote here. I avoided generators because they have worse performance, but for effects perf is not so much a problem as they run infrequently. I'm going to look into that.
So the potential new Effect API is:
const addMeshEffect = makeEffect([Model], function* (ent, game) {
const scene = game.globals.immediate('scene');
const mesh = new Mesh();
scene.add(mesh);
game.add(ent.id, Mesh, { mesh });
yield doLongTask();
}, function (ent, game) {
const scene = game.globals.immediate('scene');
const { mesh } = ent.get(Mesh);
scene.remove(mesh);
game.remove(ent.id, Mesh);
});
Less terse, because the closure is lost. That kind of sucks. And actually in this example it just happens to not really help that the first function is a generator, since the final yield
is no different than just running the task. Something to think about. At least the cleanup can be run immediately?
But at this point, is this not similar to just writing 2 different effects?
const addMeshEffect = makeEffect([Model], function* (ent, game) {
const scene = game.globals.immediate('scene');
const mesh = new Mesh();
scene.add(mesh);
game.add(ent.id, Mesh, { mesh });
yield doLongTask();
});
const removeMeshEffect = makeEffect([not(Model), Mesh], function* (ent, game) {
const scene = game.globals.immediate('scene');
const { mesh } = ent.get(Mesh);
scene.remove(mesh);
game.remove(ent.id, Mesh);
});
While this is even more code, it's more decomposed and cleaner I think. So perhaps just removing cleanup entirely is the right call here.
Let's think up another example to explore further...
const generateTerrainEffect = makeEffect([TerrainSeed], function* (ent, game) {
const voxels = yield terrainNoise(ent.get(TerrainSeed).seed);
const geometry = yield buildGeometry(voxels);
const scene = game.globals.immediate('scene');
const mesh = new Mesh(geometry);
scene.add(mesh);
game.add(ent.id, Mesh, { mesh });
});
const disposeTerrainEffect = makeEffect([not(TerrainSeed)], function* (ent, game) {
const scene = game.globals.immediate('scene');
const mesh = ent.get(Mesh);
if (mesh) {
scene.remove(mesh.mesh);
game.remove(ent.id, Mesh);
}
});
Oof, that's a tricky one! Because not(TerrainSeed)
will match any entity not even related to TerrainSeed (like the player), and now if they have a Mesh it will be removed frame 1 for no reason.
What might be able to solve this issue is introducing a removed
filter which mandates that the component was in fact attached to the entity last frame. But powering that might be hard.