add voxel world runtime navmesh sidecar

Introduce a DI-wired NavMesh sidecar for voxel chunks so world streaming stays actor-driven and world state remains the canonical source for navigation rebuilds.
This commit is contained in:
Alexander Borisov
2026-04-08 11:28:39 +03:00
parent 4e1cf273fa
commit 055b87a85c
22 changed files with 1095 additions and 8 deletions
@@ -2,7 +2,9 @@
"name": "VoxelWorld.Runtime",
"rootNamespace": "InfiniteWorld.VoxelWorld",
"references": [
"UniTask"
"UniTask",
"VoxelWorld.Contracts",
"MessagePipe"
],
"includePlatforms": [],
"excludePlatforms": [],
@@ -1,11 +1,14 @@
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using InfiniteWorld.VoxelWorld.Contracts;
using MessagePipe;
using UnityEngine;
using UnityEngine.AI;
namespace InfiniteWorld.VoxelWorld
{
public sealed partial class VoxelWorldGenerator : MonoBehaviour
public sealed partial class VoxelWorldGenerator : MonoBehaviour, IChunkNavSourceReader, IWorldInterestReader
{
[Header("References")]
[SerializeField] private Transform streamTarget;
@@ -36,8 +39,12 @@ namespace InfiniteWorld.VoxelWorld
private int regionRebuildSession;
private VoxelWorldAtlas atlas;
private int atlasBiomeCount;
private int interestVersion;
private bool regionRebuildLoopRunning;
private VoxelWorldResolvedSettings settings = VoxelWorldResolvedSettings.Default;
private IPublisher<ChunkNavGeometryReadyMessage> chunkNavGeometryReadyPublisher;
private IPublisher<ChunkNavGeometryRemovedMessage> chunkNavGeometryRemovedPublisher;
private IPublisher<WorldInterestChangedMessage> worldInterestChangedPublisher;
private int chunkSize => settings.ChunkSize;
private int generationRadius => settings.GenerationRadius;
@@ -67,6 +74,8 @@ namespace InfiniteWorld.VoxelWorld
private int maxNeighborRefreshesPerFrame => settings.MaxNeighborRefreshesPerFrame;
private int renderRegionSizeInChunks => settings.RenderRegionSizeInChunks;
private int maxRegionBuildsPerFrame => settings.MaxRegionBuildsPerFrame;
public float ChunkWorldSize => chunkSize;
public int InterestVersion => interestVersion;
private void Awake()
{
@@ -75,7 +84,6 @@ namespace InfiniteWorld.VoxelWorld
EnsureRuntimeData();
EnsureChunkRoot();
EnsureRegionRoot();
TryResolveStreamTarget();
}
private void Update()
@@ -196,21 +204,100 @@ namespace InfiniteWorld.VoxelWorld
private bool TryResolveStreamTarget()
{
if (streamTarget != null)
return streamTarget != null;
}
public void BindWorldContracts(
IPublisher<ChunkNavGeometryReadyMessage> chunkNavGeometryReadyPublisher,
IPublisher<ChunkNavGeometryRemovedMessage> chunkNavGeometryRemovedPublisher,
IPublisher<WorldInterestChangedMessage> worldInterestChangedPublisher)
{
this.chunkNavGeometryReadyPublisher = chunkNavGeometryReadyPublisher;
this.chunkNavGeometryRemovedPublisher = chunkNavGeometryRemovedPublisher;
this.worldInterestChangedPublisher = worldInterestChangedPublisher;
}
public void SetStreamTarget(Transform target)
{
if (streamTarget == target)
{
return true;
return;
}
Camera mainCamera = Camera.main;
if (mainCamera == null)
streamTarget = target;
interestVersion++;
worldInterestChangedPublisher?.Publish(new WorldInterestChangedMessage(interestVersion));
}
public void GetInterestPoints(List<WorldInterestPoint> results)
{
if (results == null)
{
throw new ArgumentNullException(nameof(results));
}
if (streamTarget == null)
{
return;
}
results.Add(new WorldInterestPoint(streamTarget.position, 1f, WorldInterestKind.PlayerActor));
}
public void GetLoadedChunkCoords(List<Vector2Int> results)
{
if (results == null)
{
throw new ArgumentNullException(nameof(results));
}
foreach (KeyValuePair<Vector2Int, ChunkRuntime> pair in chunks)
{
if (HasNavGeometry(pair.Value))
{
results.Add(pair.Key);
}
}
}
public bool TryGetChunkNavSourceSnapshot(Vector2Int coord, out ChunkNavSourceSnapshot snapshot)
{
if (!chunks.TryGetValue(coord, out ChunkRuntime runtime) || !HasNavGeometry(runtime))
{
snapshot = default;
return false;
}
streamTarget = mainCamera.transform;
ChunkNavBuildSourceDescriptor groundSource = ChunkNavBuildSourceDescriptor.CreateBox(
Matrix4x4.TRS(
runtime.GroundCollider.transform.TransformPoint(runtime.GroundCollider.center),
runtime.GroundCollider.transform.rotation,
runtime.GroundCollider.transform.lossyScale),
runtime.GroundCollider.size);
if (runtime.ColliderMesh != null && runtime.ColliderMesh.vertexCount > 0)
{
snapshot = new ChunkNavSourceSnapshot(
coord,
runtime.Version,
new[]
{
groundSource,
ChunkNavBuildSourceDescriptor.CreateMesh(runtime.MountainCollider.transform.localToWorldMatrix, runtime.ColliderMesh)
});
return true;
}
snapshot = new ChunkNavSourceSnapshot(coord, runtime.Version, new[] { groundSource });
return true;
}
private static bool HasNavGeometry(ChunkRuntime runtime)
{
return runtime != null && runtime.Root != null && runtime.GroundCollider != null && runtime.State == ChunkState.Rendered;
}
private void ScheduleChunkGeneration(Vector2Int centerChunk)
{
List<Vector2Int> coords = GetCoordsByPriority(centerChunk, generationRadius);
@@ -284,6 +371,7 @@ namespace InfiniteWorld.VoxelWorld
Vector2Int regionCoord = ChunkToRegion(coord);
MarkRegionDirty(coord);
PublishChunkNavGeometryRemoved(coord, runtime.Version);
chunks.Remove(coord);
runtime.Dispose();
TryDisposeRegionIfEmpty(regionCoord);
@@ -415,10 +503,21 @@ namespace InfiniteWorld.VoxelWorld
}
runtime.ApplyColliderMesh(pending.ColliderMesh);
PublishChunkNavGeometryReady(pending.Coord, runtime.Version);
applies++;
}
}
private void PublishChunkNavGeometryReady(Vector2Int coord, int version)
{
chunkNavGeometryReadyPublisher?.Publish(new ChunkNavGeometryReadyMessage(coord, version));
}
private void PublishChunkNavGeometryRemoved(Vector2Int coord, int version)
{
chunkNavGeometryRemovedPublisher?.Publish(new ChunkNavGeometryRemovedMessage(coord, version));
}
private void QueueNeighborRefresh(Vector2Int coord)
{
if (!queuedNeighborRefreshes.Add(coord))