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
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9b8ddf3935be4c6da0df53dfe0792909
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,116 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;
namespace InfiniteWorld.VoxelWorld.Contracts
{
public interface IChunkNavSourceReader
{
float ChunkWorldSize { get; }
void GetLoadedChunkCoords(List<Vector2Int> results);
bool TryGetChunkNavSourceSnapshot(Vector2Int coord, out ChunkNavSourceSnapshot snapshot);
}
public interface IWorldInterestReader
{
int InterestVersion { get; }
void GetInterestPoints(List<WorldInterestPoint> results);
}
public readonly struct ChunkNavSourceSnapshot
{
public ChunkNavSourceSnapshot(Vector2Int coord, int version, ChunkNavBuildSourceDescriptor[] sources)
{
Coord = coord;
Version = version;
Sources = sources;
}
public Vector2Int Coord { get; }
public int Version { get; }
public ChunkNavBuildSourceDescriptor[] Sources { get; }
}
public readonly struct ChunkNavBuildSourceDescriptor
{
public ChunkNavBuildSourceDescriptor(NavMeshBuildSourceShape shape, Matrix4x4 transform, Vector3 size, Mesh mesh, int area)
{
Shape = shape;
Transform = transform;
Size = size;
Mesh = mesh;
Area = area;
}
public NavMeshBuildSourceShape Shape { get; }
public Matrix4x4 Transform { get; }
public Vector3 Size { get; }
public Mesh Mesh { get; }
public int Area { get; }
public static ChunkNavBuildSourceDescriptor CreateBox(Matrix4x4 transform, Vector3 size, int area = 0)
{
return new ChunkNavBuildSourceDescriptor(NavMeshBuildSourceShape.Box, transform, size, null, area);
}
public static ChunkNavBuildSourceDescriptor CreateMesh(Matrix4x4 transform, Mesh mesh, int area = 0)
{
return new ChunkNavBuildSourceDescriptor(NavMeshBuildSourceShape.Mesh, transform, Vector3.zero, mesh, area);
}
}
public readonly struct WorldInterestPoint
{
public WorldInterestPoint(Vector3 position, float priority, WorldInterestKind kind)
{
Position = position;
Priority = priority;
Kind = kind;
}
public Vector3 Position { get; }
public float Priority { get; }
public WorldInterestKind Kind { get; }
}
public enum WorldInterestKind
{
PlayerActor = 0,
ActiveNpc = 1,
Other = 2
}
public readonly struct ChunkNavGeometryReadyMessage
{
public ChunkNavGeometryReadyMessage(Vector2Int coord, int version)
{
Coord = coord;
Version = version;
}
public Vector2Int Coord { get; }
public int Version { get; }
}
public readonly struct ChunkNavGeometryRemovedMessage
{
public ChunkNavGeometryRemovedMessage(Vector2Int coord, int version)
{
Coord = coord;
Version = version;
}
public Vector2Int Coord { get; }
public int Version { get; }
}
public readonly struct WorldInterestChangedMessage
{
public WorldInterestChangedMessage(int version)
{
Version = version;
}
public int Version { get; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 55dd8e2a1b2d458aa96895a54d53e6ca
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,14 @@
{
"name": "VoxelWorld.Contracts",
"rootNamespace": "InfiniteWorld.VoxelWorld.Contracts",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 96b902ea5b554a1b8a9e0c29e03118f2
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -10,6 +10,8 @@ GameObject:
m_Component:
- component: {fileID: 74135865886311664}
- component: {fileID: 2927522923773808063}
- component: {fileID: 6182401849027620011}
- component: {fileID: 6182401849027620012}
m_Layer: 0
m_Name: VoxelWorld
m_TagString: Untagged
@@ -47,3 +49,42 @@ MonoBehaviour:
streamTarget: {fileID: 0}
config: {fileID: 11400000, guid: b8cf28a5522134b479c23f017234070c, type: 2}
_terrainShader: {fileID: 4800000, guid: ec80aebd8cb61f44cbfa6b7d5f087211, type: 3}
--- !u!114 &6182401849027620011
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 797018065588400165}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0c52a16bd6e44739b6bb1b4471a7a5a9, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::VoxelWorldScene.VoxelWorldPlayerStreamTargetBinding
worldGenerator: {fileID: 2927522923773808063}
explicitStreamTarget: {fileID: 0}
--- !u!114 &6182401849027620012
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 797018065588400165}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2dfd0b7ddf3a419f91ce891210f85d4b, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::VoxelWorldScene.VoxelWorldNavMeshLifetimeScope
parentReference:
TypeName:
autoRun: 1
autoInjectGameObjects: []
enableRuntimeNavMesh: 1
worldGenerator: {fileID: 2927522923773808063}
config:
agentTypeId: 0
navRegionSizeInChunks: 2
maxNavMeshBuildsPerFrame: 1
navBoundsHorizontalPadding: 1
navBoundsVerticalPadding: 2
navWarmupRadiusInRegions: 1
@@ -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))