Open-World Streaming
An open world is far bigger than memory, so only the part near the player can be loaded at any moment, and the rest has to stream in as the player moves, without a loading screen, a stutter or a hole in the horizon. This page builds that layer on top of File Streaming's fast reads and Spatial Partitioning's grids: cut the world into cells, load a window of them around the player, and keep the frame smooth while doing it. The part that surprises people is where the stutter usually comes from: on modern hardware, more often the main-thread work of making loaded data live than the disk read itself.
- Cut a world into streaming cells, and pick a cell size and load radius for its density and how fast the player moves.
- Order load requests by distance, visibility and predicted travel, and know when prediction backfires.
- Tell a streaming hitch from pop-in, and fix the hitch with a per-frame integration budget instead of a faster disk.
- Evict with hysteresis so a player pacing a boundary doesn't thrash, and save world changes as a delta over the cooked baseline.
- Keep the horizon visible and inside a triangle budget with LOD and HLOD proxies.
01The world doesn't fit
The player can ride to any mountain on the horizon without a loading screen, but the whole world will never fit in memory at once. A modern open world is tens to hundreds of gigabytes on disk; runtime memory is a fraction of that. The 's job is to keep resident only the slice of the world near the camera, paging cells in as the player approaches and out as they recede. Mark Cerny's PS5 talk framed the change in exactly these terms: on PS4, system memory had to hold everything the next 30 seconds or so of gameplay might use, while a fast enough SSD lets a game load data when it turns out to be needed instead of parking it in RAM in advance[1].
Worked example
Say a 16 × 16 km world is cut into 256 m squares: 16,000 / 256 = 62.5 cells a side, about 3,900 cells in all. Load everything within 768 m of the player and the circle covers π × 768² / 256² ≈ 28 cells, under 1% of the world. The streamer's whole job is keeping the right 28 resident as the player moves.
Keep RAM and VRAM separate. Gameplay data, audio, navigation, and collision live in system memory; meshes and textures are uploaded to GPU memory. Each pool has its own budget and its own eviction policy, so a cell can be RAM-resident while its textures are still streaming into VRAM. This tutorial sits above the I/O streamer and the residency pool you built in File Streaming; it decides which cells to ask that streamer for.
02Partitioning into cells
Streaming needs a unit: something small enough to load in the background and large enough that the loader isn't juggling millions of separate objects. Cut the world into a grid of cells (sometimes called sectors or tiles): each cell owns the actors, meshes, and collision in its footprint, baked as one loadable unit. The cell the player stands in plus a radius of neighbors is what stays resident. Unreal's World Partition does this with a 2D runtime spatial hash grid by default, which removed the need to split large maps into hand-managed sublevels[2]. The grid-versus-quadtree mechanics are the Spatial Partitioning tutorial's territory; here the only new state per cell is its residency.
Cell size has to be small enough that loading one doesn't spike memory or the frame, but large enough that cells don't churn (load and unload rapidly) as the player moves near a boundary[3]. There is no universal number. Epic's World Partition documentation illustrates the settings with 256 m cells and a 768 m loading range[2], and the StraySpark guide suggests anything from 64 m to 256 m depending on density[3]. A regular grid is cheap to build and extends in any direction, but wastes resolution where the world is sparse; a quadtree adapts to uneven density at a higher update cost. Grids are the common choice; the right one depends on how evenly the world's content is spread.
Worked example
Take Epic's illustration: 256 m cells and a 768 m loading range, 3 cells of radius. A player riding at 30 m/s (108 km/h) crosses a cell every 256 / 30 ≈ 8.5 s, and each crossing brings roughly one new row of cells across the circle's leading edge, about 2 × 768 / 256 = 6 of them. That's around 6 cells every 8.5 s, 0.7 cells a second, that the loader has to sustain. Halve the cell size and the rows come twice as often with twice as many cells each, but each cell is a quarter of the size: the same data per second, cut into smaller, more frequent loads.
The map below streams cells around a wandering player. It starts with the cells around the player resident, as if a loading screen had filled them. Cells inside the load radius queue, then load over real time; cells past the eviction radius are freed. Drop the bandwidth or raise the speed until the player outruns the loader and the player dot turns red: that's pop-in:
03Loading radius & priority
A dozen cells are queued and the loader can fetch a few a second. Which goes first? The set of cells to keep resident is a streaming volume around each streaming source (usually the camera, sometimes also a fast vehicle or a spectated player). Within that volume, requests are ordered by priority rather than issued all at once. A common priority blends three signals.
- Distance. Nearer cells first; they are what the player reaches soonest.
- Visibility. Cells inside the view frustum outrank cells behind the camera. Unreal's texture streamer uses the same family of heuristic, ranking by the on-screen texel-to-pixel ratio and whether a thing is visible at all[4].
- Velocity prediction. Bias the volume along the predicted path so a cell finishes loading before the player arrives, rather than as they cross into it.
Worked example
Say the score is distance in meters, doubled for cells outside the view frustum, and lower loads first. Cell A is 300 m away behind the camera and scores 600; cell B is 500 m away straight ahead and scores 500, so B loads first even though it's farther. Prediction moves the volume itself: the §2 widget centers its ring 0.6 s ahead of travel, which at its top speed of 2.2 cells a second puts the center 1.3 cells in front of the player.
Predicting ahead of travel only helps while the prediction holds. On a sharp turn the velocity is stale, so a cell finishes loading where the player was about to be instead of where they went. In the §2 widget that shows up after the player bounces off the map edge: cells that were loading ahead of the old heading land behind the new one. Predicting harder makes this worse. The fix is a load radius large enough that the unpredicted neighbors are resident anyway, and cheap eviction for the mispredicted cells.
04Getting the bytes
Read a cell's file on the thread that draws frames and the game freezes for as long as the disk takes. A queued cell's data is read on a worker thread, never on the thread that draws frames. This is the stage most often assumed to be the bottleneck. The File Streaming tutorial built the async read path and the residency pool; the streamer dispatches cell loads onto the job system and is handed back a buffer when the read completes.
On Windows, open with FILE_FLAG_OVERLAPPED and drive completions through an I/O completion port (IOCP); on Linux, io_uring batches many reads through one io_uring_enter via its submission/completion rings[5]. In Rust, tokio::fs is not truly asynchronous: it runs ordinary blocking file operations on a spawn_blocking thread pool, because, in Tokio's words, "most operating systems do not provide asynchronous file system APIs"[6]. For real async file I/O on Linux you reach for an io_uring crate. Either way, the bytes arriving is not the same as the cell being usable, which is §5.
Worked example
Say a cell is 40 MB. An NVMe SSD reading roughly 3.5 GB/s delivers it in about 11 ms, on a worker thread, while frames keep drawing. A hard disk at roughly 100 MB/s takes about 400 ms before seeks, which is why slow disks do cause pop-in. On the SSD the read is long finished by the time the cell's turn comes to go live.
05The integration budget
The game holds 60 fps on an NVMe SSD, yet it stutters for one frame every time the player crosses into a new region. The load-bearing idea of the whole tutorial: a streaming (one frame that blows the budget) is usually not the disk read. On an SSD the bytes are already in RAM by the time you notice. A common cause is the main-thread cost of making the loaded data live: registering components, building GPU resources, adding primitives to the scene, creating physics bodies. In Unreal, making a streamed level visible runs AddToWorld, which applies the level transform, updates and registers components, initializes actors and routes their initialization functions[9]; Epic's level-streaming hitching guide covers how to spot these hitches in Unreal Insights captures[7]. Unity's Addressables documentation says the same of scene loads: the load is async, but "the final part of scene loading requires operation on the main thread. This can be blocking."[8]
Give the per-frame activation work a time budget and stop when it's spent, finishing the cell over several frames. Unreal exposes this directly: s.LevelStreamingActorsUpdateTimeLimit caps the time spent in AddToWorld per frame, and component registration is sliced by a granularity count (s.LevelStreamingComponentsRegistrationGranularity); set the limit too high and "you will see a lot of hitches if your levels are content heavy"[9]. A second, distinct main-thread spike is the garbage collector running after a cell streams out; Unreal can force a full collection after a level streams out or run it incrementally while levels wait to be purged, and the incremental setting avoids the spike[9]. The disk is a cause of stalls on a slow HDD or when bandwidth-starved; on modern hardware, integration is the more common one, and the two need different fixes.
Worked example
The widget below models a frame whose other work costs 9 ms, and a cell that brings 14 ms of activation. All at once, that frame takes 9 + 14 = 23 ms: 6.3 ms over the 16.7 ms line, one visible hitch per cell. With a 2 ms budget the same work takes 14 / 2 = 7 frames of 11 ms each, and the cell goes live 7 frames, about 117 ms, later. The frame has 16.7 − 9 = 7.7 ms of slack, so the budget has to stay under that: at 8 ms, the sliced frames take 17 ms and hitch anyway.
A cell finishes loading every second or so and brings a chunk of main-thread integration work. Integrate it all at once and the frame bar spikes over the 16.7 ms line (a hitch); time-slice it and the frame stays smooth while the cell takes a few extra frames to go live:
// Worker thread (NOT the frame thread): read the cell, then queue it for activation.
void onCellLoaded(Cell& cell, Bytes raw) {
cell.deserialize(raw); // parse off-thread; still not "live"
std::lock_guard lock(handoffMutex); // workers and the frame thread share this list
loadedCells.push_back(&cell); // hand to the main thread
}
// Main thread, once per frame: spend only the budget, finish the rest next frame.
void integrateStreamedCells(std::chrono::microseconds budget) {
{
std::lock_guard lock(handoffMutex); // take what the workers finished; lock held briefly
pendingIntegration.insert(pendingIntegration.end(), loadedCells.begin(), loadedCells.end());
loadedCells.clear();
}
auto sliceStart = std::chrono::steady_clock::now();
while (!pendingIntegration.empty()) {
if (std::chrono::steady_clock::now() - sliceStart > budget) break; // time-slice (cf. UE's update time limit)
Cell* cell = pendingIntegration.front();
if (cell->integrateNextChunk()) // register a few components / upload one batch
pendingIntegration.pop_front(); // the cell is fully live
}
}
// Worker thread: read + parse the cell, then hand it to the main thread for activation.
fn on_cell_loaded(mut cell: Cell, raw: Bytes, loaded: &Sender<Cell>) {
cell.deserialize(raw); // parse off-thread; still not "live"
loaded.send(cell).ok(); // move it to the frame thread
}
// Main thread, once per frame: spend only the budget, finish the rest next frame.
fn integrate_streamed_cells(world: &mut World, loaded: &Receiver<Cell>, budget: Duration) {
world.pending_integration.extend(loaded.try_iter()); // take what the workers finished
let slice_start = Instant::now();
while let Some(cell) = world.pending_integration.front_mut() {
if slice_start.elapsed() > budget { break; } // time-slice
if cell.integrate_next_chunk() { // register / upload one quantum
let live = world.pending_integration.pop_front().unwrap();
world.add_live_cell(live); // fully live
}
}
}
This skips the production concerns the streamer also needs: a pin list so a cell in use this frame can't be evicted mid-frame; GPU upload fences (issuing the copy is not the same as the copy being done); dependency ordering so a cell's textures are resident before a mesh references them; double-buffered staging; bandwidth-aware throttling; and decompression-worker backpressure. The budget check also runs between chunks, so one long chunk still overshoots; engines keep chunks small for that reason (Unreal's registration granularity above). The File Streaming tutorial owns the residency-pool and eviction internals this builds on.
06Eviction & persistence
Memory fills as the player explores, so something has to go. Cells leave memory when the player moves away. A streaming-memory counter or a distance or LRU policy decides what goes. The GTA III-era engine kept a counter that rises by each resource's size on load and falls on unload; over the limit, it frees resources nothing is using first, then buildings, preferring ones out of sight and far away[10]. The less obvious question is when to evict.
If you load and evict a cell at the same radius, a player pacing back and forth across a boundary loads and frees the same cell every step: the load-evict thrash. Load at radius R and evict at a larger radius R + margin, so a cell has to be clearly gone before it's dropped. It's the streaming version of a Schmitt trigger: an asymmetric threshold that kills oscillation. The §2 widget evicts farther than it loads for exactly this reason.
Worked example
The §2 widget loads within 3 cells of the volume's center and evicts beyond 4.5. A player pacing one cell back and forth moves a cell's distance between, say, 2.8 and 3.8: it loads at 2.8 and is never far enough to be freed. The price is memory. The load circle covers π × 3² ≈ 28 cells, and the band out to 4.5 can hold up to π × (4.5² − 3²) ≈ 35 more, though only cells the player recently passed are actually in it.
The cooked cell on disk is read-only and shared. When the player changes it (opens a door, loots a chest, fells a tree, kills an NPC), you do not rewrite the cooked cell. You record a delta layered on top of the baseline, and that delta is what the save system serializes. This keeps the world's authored data immutable and the per-player mutation tiny. It's the common approach for level-based worlds, not the only one: a single-scene or roguelike game may snapshot whole cells, and an MMO keeps authoritative state on the server.
Worked example
A cell holds 2,000 placed objects. The player opens three doors and loots one chest. Say each change is recorded as an 8-byte object ID plus 8 bytes of new state: the delta is 4 × 16 = 64 bytes, and the cooked cell on disk stays untouched. When the cell streams back in, the loader reads the baseline and then applies those four records.
07LOD, HLOD & the far field
A cell beyond the load radius still has to be seen if it's on the horizon. Rendering it at full detail blows the budget; showing nothing leaves a hole. The answer is detail that falls off with distance: full meshes near, lower at mid range, and a single baked proxy for the unloaded far cells. Unreal builds HLOD proxies offline (instanced, merged or simplified meshes) to stand in for unloaded World Partition cells and cut draw calls in the distance[11].
Worked example
With the player at the center of the widget's 18 × 10 grid: 16 full cells at 100k triangles are 1.6M, 44 LOD cells at 28k are 1.23M, and 120 far cells as 4k proxies are 0.48M, about 3.3M in all, under the 5M budget. Without HLOD those 120 far cells cost 100k each, 12M on their own, and the total is 14.8M, about three times the budget.
The widget shows concentric rings of detail around the player, with illustrative per-cell triangle counts. Near cells are full meshes, mid cells drop to a cheaper LOD, far cells collapse to an HLOD proxy. Toggle HLOD off and the far field reverts to full meshes and blows the triangle budget; the other alternative is an empty horizon that pops in as you approach:
Texture detail streams on the same principle one level down: pick a mip from the on-screen texel-to-pixel ratio and the memory budget, dropping one mip at a time when the plan is over budget[4]. Sparse virtual texturing takes that further, mapping a small physical cache to a huge logical texture through a page-table indirection. Many implementations decide which pages to stream from GPU feedback (a readback of which pages the visible pixels needed); clipmaps, an older scheme that suits terrain, instead keep a window of each mip level centered on the viewpoint and need no feedback pass[12]. The File Streaming tutorial derives SVT and the I/O fast path in full. Texture streaming is a related but distinct system from world-cell streaming.
Pop-in is data that hasn't arrived: a low-detail proxy (or nothing) shows, then swaps to full detail once it streams in. Its causes are throughput, priority and bandwidth; its fixes are more bandwidth, better priority, a larger prefetch radius, or an impostor to cover the gap. Hitching is a single frame that blew its budget, from main-thread integration or GC (§5); its fix is time-slicing. The platform I/O fast path, DirectStorage with GPU decompression on PC and the PS5's hardware decompressor, attacks bytes-to-memory latency, so it helps pop-in and load times, not the integration hitch[13]. A faster SSD will not fix a stutter that lives on the main thread.
Wrong answers, and why: a per-region stutter on an SSD is main-thread integration (time-slice it), not the disk or cell size; a blob-then-detail swap is pop-in (throughput), not a hitch or GC; and boundary thrash is fixed by asymmetric load/evict radii (hysteresis), not by resizing cells or never evicting.
08Pitfalls
09What's next
Streaming keeps the world resident around the player; the changes the player makes to it have to outlive the session. The next module, Save Games & Serialization, persists that mutable state: the object graph (you cannot write a pointer to disk), versioning so old saves still load, and the atomic write that keeps a crash from corrupting a save. The full path is on the series hub.
- Mark Cerny. "The Road to PS5" (2020). Talk on YouTube; coverage by Dean Takahashi, VentureBeat, "PlayStation 5: Mark Cerny's deep dive into SSD memory" (archived copy). On PS4, system memory held everything the next 30 seconds or so of gameplay might use; a fast SSD lets data load when it is needed.
- Epic Games. "World Partition in Unreal Engine." dev.epicgames.com. The default 2D runtime hash grid of streaming cells, streaming sources, an example configuration of 256 m cells with a 768 m loading range, and that it removes the need to split large maps into sublevels.
- StraySpark (2026). "World Partition Deep Dive: Streaming, Data Layers, and HLOD for Massive Open Worlds." strayspark.studio. Cell size as a churn-versus-memory-spike tradeoff, with 64 m, 128 m and 256 m cells compared.
- Epic Games. "Texture Streaming Overview" (Unreal Engine 4.27). dev.epicgames.com. Mip selection from the resolution needed for a good texel-per-pixel ratio plus visibility; visible mips load first; over budget, the planned quality drops one mip at a time.
- Oracle. "An Introduction to the io_uring Asynchronous I/O Framework." blogs.oracle.com. The submission/completion rings and batching many reads through one
io_uring_enter. - Tokio.
tokio::fsmodule documentation. docs.rs. File operations run as ordinary blocking calls on aspawn_blockingthread pool "because most operating systems do not provide asynchronous file system APIs"; io_uring is named as a possible future backend. - Epic Games. "Level Streaming Hitching Guide." dev.epicgames.com. How to recognize common level-streaming hitches in Unreal Insights captures, with links to settings that address them.
- Unity Technologies. Addressables
LoadSceneAsync. docs.unity3d.com. The load is async, but "the final part of scene loading requires operation on the main thread. This can be blocking." - Peter Leontev. "Level Streaming and Garbage Collection Optimization Tweaks in UE4." peterleontev.com. What
AddToWorlddoes when a level becomes visible,s.LevelStreamingActorsUpdateTimeLimittime-slicing it across frames, the component-registration granularity, and forced versus incremental GC after levels stream out. - GTAMods Wiki. "Resource Streaming." gtamods.com. The GTA III / Vice City / San Andreas streamer: a five-state pipeline per resource, a streaming-memory counter driving eviction (unused resources first, then distant and out-of-sight buildings), and disc reads ordered by position on the disc.
- Epic Games. "World Partition — Hierarchical Level of Detail." dev.epicgames.com. Offline-built HLOD layers (instanced, merged or simplified meshes) standing in for unloaded grid cells to cut distant draw calls.
- J.M.P. van Waveren. "Software Virtual Textures" (2012). mrelusive.com; Andreas Neu. "Virtual Texturing" (arXiv:1005.3163, 2010). arxiv.org. The page-table indirection and the GPU-feedback residency path (van Waveren); Neu's survey of earlier schemes, including clipmaps, which clip each mip level to a window around the viewpoint and suit terrain.
- NVIDIA. "Accelerating Load Times for DirectX Games and Apps with GDeflate for DirectStorage." developer.nvidia.com. GPU decompression speeds bytes-to-VRAM (load time and pop-in), which is a different leg from the main-thread integration hitch.