Skip to content

Large worlds

Worlds bigger than memory and bigger than a float: stream content in cells around the player, and move the origin so positions stay precise kilometres out.

module: awake:scene:world module: awake:world module: awake:scene:worldstream Desktop · Android · iOS · Web

awake:scene:world streams cell coordinates and shifts the origin; it does not know what a cell contains. The cell types it streams, WorldCellCoord, WorldPartitionConfig, AsyncWorldCellStreamListener and CellContent, are in awake:world, which has no scene dependency, so a listener can be written without the scene layer; awake:scene:world brings it in. awake:scene:worldstream supplies ready-made cell contents: meshes, colliders and terrain textures. All three are code-only; there is no scene document component for streaming.

Stream cells around the player

The world is cut into square cells. WorldPartitionSystem follows the entity tagged StreamObserver: cells whose centre comes within loadingRadius load, and cells beyond unloadRadius unload. A listener decides what a cell holds:

Kotlin
/** Spawns one named rock per cell, and removes it when the cell streams out. */
class RockCells : AsyncWorldCellStreamListener {
    private val spawned = HashMap<WorldCellCoord, List<Entity>>()

    override suspend fun loadCell(coord: WorldCellCoord): CellContent {
        // Off the frame thread: read files, decode, bake. No World access here.
        val names = listOf("rock ${coord.x},${coord.z}")
        // On the frame thread, and only if the cell is still in range.
        return CellContent { world ->
            spawned[coord] = names.map { name -> world.create().also { world.add(it, Name(name)) } }
        }
    }

    override fun onCellUnload(world: World, coord: WorldCellCoord) {
        spawned.remove(coord)?.forEach(world::destroy)
    }
}

Give the system the listener and a scope to load in, and tag the player:

Kotlin
val partition = WorldPartitionSystem(
    config = WorldPartitionConfig(cellSize = 256f, loadingRadius = 512f, unloadRadius = 768f),
    asyncStreamListener = RockCells(),
    loadScope = this, // a CoroutineScope that outlives the scene; the runtime has one
)

val player = world.create()
world.add(player, Transform(position = Vec3f(0f, 0f, 0f)))
world.add(player, StreamObserver) // streaming follows this entity

For cells built from memory, implement WorldCellStreamListener instead: its onCellLoad runs on the frame thread and needs no scope. To fill a cell from several sources, combine listeners with CompositeCellStreamListener.

Properties

Property Type Default What it does
cellSize number 512 Width and depth of one cell, in metres.
loadingRadius number 1024 Cells within this distance of the observer load. At least cellSize.
unloadRadius number 1536 Cells beyond this distance unload. More than loadingRadius.

These are WorldPartitionConfig fields. The gap between the two radii stops a cell from loading and unloading every frame while the player stands on its edge.

Keep positions precise

A Float holds about seven significant digits, so 10 km from the origin positions snap to the nearest millimetre and the view jitters. FloatingOriginSystem moves the world back towards the origin whenever the observer gets too far out:

Kotlin
val origin = FloatingOriginSystem(threshold = 2048f, quantum = 1024f)
// With physics: origin.addListener(PhysicsOriginShiftListener(physicsWorld))

origin.update(world, 0f)

val local = requireNotNull(world.get<Transform>(player)).position
val absolute = requireNotNull(world.findWorldOrigin()).toAbsolute(local)
Property Type Default What it does
threshold number 2048 Distance from the origin, on any axis, that triggers a shift.
quantum number 1024 Size of each shift step. Shifts are whole steps, so they are exact.

WorldOrigin records the total shift; toAbsolute and toLocal convert between the two frames. WorldPartitionSystem works in absolute coordinates, so a shift never changes which cells are loaded.

Tell physics about the shift

The origin shift moves root Transforms only. PhysicsSystem writes the simulation's pose back every step, so without origin.addListener(PhysicsOriginShiftListener(physicsWorld)) every body snaps back to where it was. Navigation grids and other caches of world positions need their own OriginShiftListener.

Ready-made cell contents

awake:scene:worldstream has listeners for common cell contents:

Type Loads per cell
MeshCellStreamer Mesh entities and their renderers.
PhysicsCellStreamer Static physics bodies.
heightFieldCellStreamer(...) Heightfield collider tiles; tilesTouchedByEdit lists the tiles to rebuild after a terrain edit.
VirtualTerrainCellStreamListener Terrain texture tiles from a VirtualTerrainTileProvider, kept in a VirtualTerrainTileCache.
WorldstreamTerrainDriver Wires the streamers as the scene runtime's TerrainDriver.

awake:navigation adds NavGridCellStreamer, which bakes a navigation tile per cell. See Navigation.

How it works

Each update, WorldPartitionSystem first applies cells that finished loading, then compares the observer's position with the active cells. A new cell starts loadCell in the load scope, which runs off the frame thread and returns a CellContent. The next update applies that content on the frame thread, and only if the cell is still active. A cell that leaves the radius has its load cancelled and onCellUnload called.

Only the first observer counts

Streaming follows the first entity with both StreamObserver and a Transform. An observer without a Transform is ignored.

Follow the player, not the camera

Tag the player entity. A camera that orbits or cuts away would drag cells in and out behind it.

See also

  • Terrain for the heightmaps streamed cells are built from.
  • Physics for colliders and shiftOrigin.
  • Navigation for streamed navigation grids.