Builtin Library
readme.md
spatialStreaming
World content that becomes resident around a moving source and is released behind it, so the memory a world costs follows the view rather than the world's total size.
The shape of it
- A cell is a square column on the ground plane,
cellSizeon a side. - A group is one root entity and its descendants, serialized as records. A group is atomic — a subtree is never half resident — and it belongs to the cell its root stands in.
- A source is a position streaming happens around: an entity (a player, a camera) or a fixed point. Each carries its own radii, and a cell stands as long as any one of them holds it.
- A layer is a name a group is filed under, and each layer is its own grid. A layer carries its own radii and can be switched off, so decorative content releases long before the content a player interacts with — which only works because the two can stand in the same square and still stream apart.
- A proxy is what a cell is drawn as while its content is released: its own
records, so a merged mesh, an impostor or a bounding silhouette are all
expressible. It stands out to
proxyRadius, which reaches well past the radius the content is bought at — and follows that radius unless a world names one of its own.
A cell within loadRadius of the nearest source is made resident, past
unloadRadius it is released, and between the two it keeps whatever it already
is. That band is the whole reason a source can stand on a boundary without the
cell under it loading and releasing every frame.
Using it
Attach StreamingSource to whatever the world should stay resident near:
entity.find("Player").component.add("StreamingSource", {
cellSize = 64, loadRadius = 120, unloadRadius = 180, budget = 32,
})
Then hand content to the store. capture takes entities that are already
standing and leaves them standing — residency passes to the streaming rule:
local streaming = require("@builtin::systems.spatialStreaming.spatialStreaming")
streaming.capture(entity.findAll("rock_*"), { layer = "props" })
streaming.setLayer("props", { loadRadius = 60, unloadRadius = 90 })
add registers content that was never spawned. With store = "vfs" the
records are written under the configured directory and only the cell's manifest
line stays in memory until a source arrives, which is what lets a world hold
more than it can spawn at once:
streaming.add({
{ name = "rock", position = { x = 900, y = 0, z = 40 },
components = { Model = { model = "@builtin::meshes.rock" } } },
}, { store = "vfs" })
stats() reports what the store holds and how much of it is standing:
local s = streaming.stats()
-- s.bytesResident is the encoded size of the groups standing right now;
-- s.bytesStored is the size of everything the store knows about.
The far field
A released cell is drawn as its proxy out to proxyRadius. That radius and the
band past it follow unloadRadius unless a world pins them, so content streamed
over a wide radius gets a far field past it without naming one — and a pin is
measured against the content radii, since a proxy radius inside unloadRadius
names ground the content is still standing on. Passing 0 hands a pin back:
streaming.configure({ loadRadius = 600, unloadRadius = 900 }) -- far field follows
streaming.configure({ proxyRadius = 3000, proxyUnloadRadius = 3600 }) -- or is pinned
streaming.configure({ proxyRadius = 0 }) -- and follows again
deriveProxies builds the bounding-silhouette case from the content the store
already holds — one box over a cell, or one over each group in it. Each record
is measured as a box of its own scale placed through the whole of its parent's
transform, so a group standing rotated and scaled is enclosed where it actually
stands; a mesh larger than one unit at scale 1 is measured as the unit box it
scales from, which is what padding covers:
streaming.deriveProxies({ granularity = "group", material = "@builtin::materials.default" })
setProxy takes any records instead, which is the path for an authored merged
mesh or a billboard impostor:
streaming.setProxy("4:0", {
{ name = "ridge", position = { x = 288, y = 8, z = 32 },
scale = { x = 64, y = 16, z = 64 },
components = { Model = { model = "@builtin::meshes.cube" } } },
})
The two representations are exchanged in an order that overlaps them: a cell's proxy goes up before the first of its groups is released, and comes down once the cell's own content has landed. A cell inside the proxy radius is therefore never represented by nothing, which is what the transition would otherwise read as — a hole in the far field for as long as the budget takes to fill it — and a silhouette, which is opaque, 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.
stats() prices the far field separately: proxiesResident, entitiesProxy
and bytesProxy against the entitiesTotal / bytesStored they stand in for.
proxies() is the per-cell view: records is what a stand-in is made of,
entities how much of it is standing.
What a group carries
- A group is placed by its root and streams whole. A group spanning several cells is resident whenever its root's cell is. A root that moves while it is standing is re-filed into the cell it moved into when it is released, so content that walks streams from where it ended up.
- A capture takes local content. A replicated entity is refused, with a count in the log: residency is a local decision, and despawning a replicated entity would reach every peer.
- A record carries what
component_snapshotserializes — the authored script-component surface — plus the entity's name, local transform and attributes. - A group whose root gameplay destroyed is dropped: the store forgets it rather than resurrecting deleted content on the next visit.
Several sources
Each source carries its own loadRadius / unloadRadius. A cell is resident
while any source wants it resident and is released once every source has
let go of it, so a spectator streaming a thin shell cannot pull the ground out
from under the player standing on it. cellSize and budget are world-wide:
the grid has to be one grid for a cell key to mean anything, and one step
belongs to the frame rather than to a source.
Ground
Ground streams on its own path, around the same sources: a TerrainStream
(terrainSystem.package) stands a landscape many kilometres across in tiles
that load, draw and collide around every entity carrying StreamingSource, so
the player and every vehicle a peer simulates have collision under them
wherever they go, and the content this store holds stands on ground that is
there.