Builtin Library
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.