Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commitdd348ac6c3

README.md

spatialStreaming

The store and the residency rule behind the spatialStreaming package.

The rule

Three pure functions carry the whole policy, so it can be read and tested without a scene:

  • cellKeyAt(x, z, cellSize) — the cell a point stands in.
  • cellDistance(key, cellSize, x, z) — distance to the cell's nearest edge, zero inside it. Measuring to the centre would put a cell's near corner well inside the load radius while the cell read as far, so a source walking a boundary would watch the ground it stands on get released.
  • decide(distance, loadRadius, unloadRadius) — "resident", "released", or "hold". The hold band between the radii is what stops a boundary from thrashing.
  • proxyDecide(distance, proxyRadius, proxyUnloadRadius) — the same rule over the far-field radii, for the stand-in a released cell is drawn as.

Each source runs that verdict with its own radii — its own, else its layer's, else the configuration's. A cell is resident while any source says resident and released once every source says released. A cell the budget stopped mid-move is the one case the band does not hold: it finishes the transition it was making, because half a cell is not a state a source can idle in.

The far field

Past the unload radius a cell can still be seen, so a cell carries an optional proxy: its own records, which makes a merged mesh, a billboard impostor and a bounding silhouette all expressible. setProxy / clearProxy give and take one; deriveProxies builds the silhouette case from what the store already holds, one box over a cell or one over each group. A record is measured as a box of its own scale placed through the whole of its parent's transform — scale, then rotation, then position — so a group standing rotated and scaled is enclosed where it actually stands. A mesh that is not one unit at scale 1 is measured as the unit box it scales from, which is what padding covers.

The proxy stands on its own pair of radii, proxyRadius / proxyUnloadRadius, measured from the same cell-edge distance. Left alone the pair follows the content: the far field starts a fixed multiple past unloadRadius and carries its own band, so a world that streams over a wide radius gets a far field past it without naming one. A number pins it instead, and a pin is measured — a proxy radius inside the unload radius names ground the content is standing on, so it is refused where it is authored. Passing 0 hands the pin back to the derivation. Configuration, layer and source each follow that rule: a scope that names an unload radius of its own gets a far field measured from it.

The exchange between the two representations is ordered. A cell's proxy is raised before the first of its groups is released, and dropped once the cell's own content has landed — so a cell in the proxy band is never represented by nothing, and an opaque silhouette is never left standing over the content it was covering for. A proxy whose entities left the scene by some other path is taken down whole on the next step and raised again from its records.

The store

capture(targets, opts) serializes a root and its descendants into a group and files it in the cell the root stands in, leaving the entities standing. add registers records for content that was never spawned. Either can write to a file with store = "vfs", in which case a released cell keeps only its manifest — key, layer, byte count, entity count, path — in memory.

A group is released by re-serializing it from the live subtree first, so what comes back is the state it was released in rather than the state it was captured in — including the cell it belongs to, which is re-derived from where its root now stands. A group whose root gameplay destroyed is dropped: the cell forgets it rather than resurrecting deleted content on the next visit, and the slot is closed so nothing later visits an index that names no content.

The step

tick(dt) spends at most budget entity records per frame, releases before it loads (so the peak stays at what the radius bought rather than at the sum of the cells on both sides of a boundary), and takes the nearest pending cell first.

One step belongs to the frame rather than to the caller. A second tick inside the same engine frame reports stepped = false and changes nothing, so a world carrying several StreamingSource components spends one budget per frame rather than one per source.

Parenting waits for the entities. entity.instantiate mints ids immediately but the entities land over the following drains, a bounded number per frame — so a group's links are applied at the top of each step and the ones whose child or parent has yet to land are retried on the next. The cell counts as pending until they are all applied, and a group with links outstanding is not released: re-serializing it while its children were still queued would store the root without them.

Reporting

stats() gives the residency picture: cellsResident / cellsPending, entitiesResident / entitiesTotal, and bytesResident / bytesStored — the encoded size of the groups standing now against the size of everything the store knows about. The far field is priced apart from the content it stands in for: proxiesTotal / proxiesResident, entitiesProxy and bytesProxy are what it costs right now, against the entitiesTotal / bytesStored it covers. cells() is the per-cell view (proxy, proxyResident), proxies() the per-cell view of the stand-ins — records is what one is made of, entities how much of it is standing — and residentIds() / proxyIds() the entity ids each half currently has standing.