Skip to content

World Streaming

A scene loads whole. That is the right answer until the world is bigger than a machine wants resident at once — and then the question stops being “what is drawn” and becomes “what exists”. World streaming answers the second one: the cook cuts an authored world into square cells, and at runtime a cell exists because something is near it, and stops existing when nothing is.

Nothing about this is a visibility trick. A cell that leaves is destroyed: its entities, its physics bodies, its navigating agents and its asset references all go. That is the point — a place that is merely hidden still costs memory, still collides, and still lets an enemy nobody can see attack the player.

Two components, both authored in the scene:

// On the world's settings entity: this scene is cut into 1000-unit cells.
world.insert(settings, StreamedWorld, { cellSize: 1000 });
// On everything that must never be streamed away.
world.insert(player, WorldPersistent, {});
world.insert(camera, WorldPersistent, {});
world.insert(sun, WorldPersistent, {});

Everything else is placed into a cell by where its top-level entity stands. A scene with no StreamedWorld in it is not a streamed world and loads exactly as it always did.

A cell exists because a source is near it:

world.insert(player, WorldStreamingSource, {
prefetchRadius: 3000, // cells this close are READIED, not brought in
loadRadius: 2000, // cells this close are brought in
unloadRadius: 3000, // resident cells are kept until past this
});

Several sources may exist, and what they ask for is unioned — a cell any one of them needs stays resident. That is what makes split screen, a spectator camera, a cinematic camera and an editor preview the same kind of thing rather than four special cases. Nothing folds them into a single focus, because a fold lets the last one written decide, and the frame after a second camera exists it deletes the world the first is standing in.

Distance is measured to the cell’s box, not its centre: to a centre, a source standing on a diagonal corner reads the same as one a whole cell away, which is where holes open along the diagonals of a grid.

A cell inside prefetchRadius is prepared: its document is fetched, its prefabs expanded, its assets acquired — and not one of its entities exists. So nothing can observe it. No query returns it, no renderer draws it, no body collides with it, no enemy in it decides anything, because there is nothing there yet. Publication is the only thing that creates entities, and it happens when a source actually reaches loadRadius.

That is worth about twenty milliseconds of a player’s time. On Estella’s own fixture, walking into a readied place costs 0.6 ms; walking into an unreadied one costs the fetching and decoding too.

Preparation is speculation, never authority. A cell readied for a player who turns around is thrown away and its assets given back — never published because it happened to be ready. And a preparation that finishes asks again whether the cell is still wanted, rather than assuming the answer from when it started.

prefetchRadius is never used as less than loadRadius; beyond that it is not ordered against unloadRadius at all. They answer different questions — how early to get ready, and how late to let go.

A cell comes in at loadRadius and only leaves once it is past unloadRadius. The gap between them is the whole reason both exist: a source standing on a boundary would otherwise spend every frame instantiating and destroying a place. Set unloadRadius comfortably larger than loadRadius — half again is a good starting point.

Partitioning happens at cook time, not at boot:

main.esscene ──cook──▶ main.esscene the persistent world
world/main.world.json the manifest
world/main.cell_0_0.json
world/main.cell_1_0.json ...

The entry scene the package boots is what is left after the cells are taken out of it — the things residency never removes. Cells are not switchable scenes: they are content residency brings in, and a game never names one.

Cells are cut on the XZ plane only. A cell is a vertical column, so a tower’s floors are one place.

A subtree is the unit of residency. Only top-level entities are placed, so a door cannot be sorted away from its house even if the door’s world position falls in the next cell. A cell’s box grows to cover its content, so a prop hanging over the edge does not pop in at the boundary.

Hard entity references may not cross a residency boundary. Two entities in one cell may reference each other; so may anything in the persistent world; and a cell may reference the persistent world, which outlives it. A reference from one cell into another — or from the persistent world into a cell — fails the build, because either one points at something residency is entitled to delete.

Residency owns existence; it does not own continuity. A cell that unloads loses its runtime state, and reloading rebuilds it from what was authored. An enemy at 25 health that you walk away from comes back at full health. What does survive is the entity’s authored identity, which is what a save system will be built on later.

const report = worldResidencyReport(app);
report.residentCells; // ['main.cell_1_0', 'main.cell_2_0']
report.cellEntityCounts; // live entities each resident cell owns
report.loadCount; // places brought in since boot

Counts rather than a picture, because a cell that is not drawn and a cell that does not exist look the same from a camera.

Cells load asynchronously through the same scene loading a game already uses, but there is no priority, no prefetch and no budget yet: a large cell arriving is a hitch. There is no HLOD, so a place that is not resident is not represented at all in the distance. Navigation meshes are not streamed — a world-sized navmesh stays resident. Neither is texture streaming, and the world origin is never rebased.