How the streamed world keeps a runtime NavMesh up to date without ever blocking the main thread.
Earlier versions of WorldStreamer.cs
rebuilt the NavMesh whenever a chunk loaded or unloaded. The bake itself was
asynchronous (NavMeshBuilder.UpdateNavMeshDataAsync), but every rebuild was
preceded by a synchronous setup that scaled with scene size:
NavMeshBuilder.CollectSources(...)— walked every collider/MeshFilter under the surface and built a freshList<NavMeshBuildSource>. Cost grew linearly with loaded terrains plus any procedural meshes (notably a freshly generated cave with tens of thousands of triangles).FindObjectsByType<NavMeshAgent>(...)— a full-scene object scan, also synchronous.sources.RemoveAll(s => !mesh.isReadable)— another full pass.
Together this produced a 1–3 second main-thread stall about half a second after the game started, when the first batch of chunks finished loading and the debounced rebuild fired.
We never run a full-scene scan again. Instead, source collection is per chunk, cached, and incremental.
chunk loaded chunk unloaded
│ │
▼ ▼
BeginCollect(coord) Drop(coord)
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ NavMeshSourceCache │
│ ────────────────── │
│ per-chunk committed sources │
│ + queued/active collection jobs │
│ + dirty flag │
└──────────────────────────────────────────┘
│
│ Tick() each frame, capped at
│ navMeshSourceCollectionBudgetMs
▼
committed[coord] = freshly collected list
│
▼
navMeshDirty && !HasPendingWork
│
▼
NavMeshBuilder.UpdateNavMeshDataAsync(
navMeshData, settings, flatSourceList, bounds)
│
▼
completed → ReleaseParkedAgents()
A standalone helper (not a MonoBehaviour, lives inside WorldStreamer).
BeginCollect(coord, scene, layerMask, geometry, defaultArea)— queues a collection job. The job snapshots root GameObjects from the scene immediately (so later destruction can't corrupt iteration) and then runs in slices.Tick()— called every frame by the streamer. Advances the active job until either it finishes or theperFrameBudgetMsbudget is exhausted. Default budget is 1 ms, configurable on the inspector.Drop(coord)— removes a chunk's sources and any queued/in-flight work for that coord.ConsumeDirty()— single-shot dirty flag the streamer reads before kicking a new bake.BuildFlatSourceList(output)— appends the union of all committed sources into a reusable buffer.
Sources are derived from:
- Terrains — one source per terrain (cheap, done in one slice).
- Physics colliders — for surfaces configured with
NavMeshCollectGeometry.PhysicsColliders. Box/sphere/capsule/mesh/terrain colliders are converted directly intoNavMeshBuildSourceso the engine doesn't have to re-derive them. - MeshFilters — for
RenderMeshesgeometry. A mesh whose triangle count exceeds the per-slice batch size (default 4096) is allowed to push past the budget on its own — but yields immediately after, so the worst-case per-frame cost is one large mesh per frame.
Owns one NavMeshSourceCache. Hooks:
OnOfflineSceneLoaded/HandleSceneEvent (LoadEventCompleted)→BeginChunkSourceCollection(coord)followed byScheduleNavMeshRebuild().OnOfflineSceneUnloaded/HandleSceneEvent (UnloadEventCompleted)→sourceCache.Drop(coord)followed byScheduleNavMeshRebuild().Update()callssourceCache.Tick()every frame, then checksnavMeshDirty && !sourceCache.HasPendingWork && sourceCache.ConsumeDirty() && Time.time >= navMeshRebuildTimebefore callingRebuildNavMesh().RebuildNavMesh()now just callssourceCache.BuildFlatSourceList(buffer)and hands the buffer toNavMeshBuilder.UpdateNavMeshDataAsync. No scene walk, noFindObjectsByType, noRemoveAll.
When a chunk is about to be swapped in or out, agents inside that chunk are
disabled and their target positions cached
(WorldStreamer.cs ParkAgentsForChunk).
After the async bake completes, ReleaseParkedAgents() re-enables them and
NavMesh.SamplePositions them onto the new surface.
We removed the previous global FindObjectsByType<NavMeshAgent> scan. The
NavMesh swap inside NavMeshBuilder.UpdateNavMeshDataAsync is atomic at the
engine level — agents not in a streamed chunk don't need to be parked, and the
periodic ReleaseParkedAgents tick in Update() cleans up any agent that
ends up off-mesh.
Inspector fields on the WorldStreamer GameObject:
| Field | Default | Meaning |
|---|---|---|
navMeshRebuildDelay |
0.5s | Debounce after the last chunk load/unload. Coalesces a flurry of chunk events into a single bake. |
parkedAgentActivationDistance |
32m | Max distance from the parked position when re-sampling agents onto the new surface. |
navMeshSourceCollectionBudgetMs |
1.0 ms | Per-frame time budget for NavMeshSourceCache.Tick(). Raise it for faster source readiness, lower it if you start to see frame-time spikes. |
Inside NavMeshSourceCache:
| Constant | Default | Meaning |
|---|---|---|
triangleBatchSize |
4096 | Colliders/meshes processed before re-checking the budget. A single mesh larger than this still goes through in one slice — Tick then yields. |
- Worst case per frame: 1 ms collection budget + the async bake's own per-frame cost. The async bake runs on Unity's background NavMesh thread, so its main-thread contribution is bounded.
- First-frame cost on game start: bounded to the collection budget. With ~5 starting chunks and ~10 sources each, collection finishes in 1–3 frames. The first NavMesh bake fires once the cache reports clean and the debounce window elapses.
- Per-chunk load: synchronous part is just the
GetComponentsInChildrenpass that snapshots roots; collection of actual sources is sliced. - Per-chunk unload: O(1) — drops a dictionary entry.
Cave interiors (e.g. SandstoneCaveInterior.unity) generate procedural meshes
through CaveSpawner.cs.
That generation is still synchronous on Start() and is unrelated to the
streamed world's NavMesh.
The cave already supports a baked path: assign bakedMesh and
bakedNavMeshData on the inspector and the runtime just instantiates them
(SpawnBaked()). Pre-baked assets currently exist in
Assets/Scenes/Interiors/CaveBakes/.
If a runtime cave is loaded by WorldStreamer (e.g. baked into a chunk
scene), its mesh enters the source cache through the normal per-chunk path
and contributes to the shared NavMesh without any extra plumbing.
If the cave is loaded by InteriorManager as a player transitions into an
interior, the 1–2 second generation cost is expected and falls inside the
allowed "load before going into a new scene" window.
Three rules:
- Live in a streamed chunk scene (or the persistent scene; persistent
sources need a one-shot
BeginCollectcall — currently the streamer only does this for chunk scenes). - Use a
MeshFilter/Collideron the layer mask configured on theNavMeshSurface. - Make any non-collider meshes read/write enabled (the importer sets
this; see
MeshReadablePostprocessor).
The cache picks the new geometry up automatically on the next chunk load. There is no manual rebuild call.
- NavMesh missing under fresh chunk: open the console — the rebuild log
line
"[WorldStreamer] NavMesh async rebuild started (N sources, ...)"shows the source count. If N is unexpectedly low, the chunk's geometry is likely on the wrong layer or its meshes aren't readable. - Frame-time spike during loading: open the Profiler and look at
NavMeshSourceCache.Tick(). If a single mesh is dominating, raisetriangleBatchSizeto release the per-mesh slice earlier, or lowernavMeshSourceCollectionBudgetMsto spread the work more aggressively. - Agents stuck after a rebuild:
ReleaseParkedAgents()retries every 500 ms inUpdate(). If an agent never reattaches, check thatparkedAgentActivationDistanceis wide enough to cover the displacement between its parked position and the new NavMesh.