From 055b87a85ca13b59c26a277da2385ee1f64fb932 Mon Sep 17 00:00:00 2001 From: Alexander Borisov Date: Wed, 8 Apr 2026 11:28:39 +0300 Subject: [PATCH] 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. --- Assets/Features/VoxelWorld/Contracts.meta | 8 + .../Contracts/NavMeshWorldContracts.cs | 116 ++++ .../Contracts/NavMeshWorldContracts.cs.meta | 11 + .../Contracts/VoxelWorld.Contracts.asmdef | 14 + .../VoxelWorld.Contracts.asmdef.meta | 7 + .../VoxelWorld/Prefabs/VoxelWorld.prefab | 41 ++ .../Runtime/VoxelWorld.Runtime.asmdef | 4 +- .../VoxelWorld/Runtime/VoxelWorldGenerator.cs | 113 +++- Assets/Features/VoxelWorldNavMesh.meta | 8 + .../Features/VoxelWorldNavMesh/Runtime.meta | 8 + .../Runtime/VoxelWorld.NavMesh.Runtime.asmdef | 19 + .../VoxelWorld.NavMesh.Runtime.asmdef.meta | 7 + .../Runtime/VoxelWorldNavMeshConfig.cs | 16 + .../Runtime/VoxelWorldNavMeshConfig.cs.meta | 11 + .../Runtime/VoxelWorldNavMeshService.cs | 551 ++++++++++++++++++ .../Runtime/VoxelWorldNavMeshService.cs.meta | 11 + Assets/Scripts/Players/CameraFollow.cs | 2 + Assets/Scripts/VoxelWorld.meta | 8 + .../VoxelWorldNavMeshLifetimeScope.cs | 51 ++ .../VoxelWorldNavMeshLifetimeScope.cs.meta | 11 + .../VoxelWorldPlayerStreamTargetBinding.cs | 75 +++ ...oxelWorldPlayerStreamTargetBinding.cs.meta | 11 + 22 files changed, 1095 insertions(+), 8 deletions(-) create mode 100644 Assets/Features/VoxelWorld/Contracts.meta create mode 100644 Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs create mode 100644 Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs.meta create mode 100644 Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef create mode 100644 Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef.meta create mode 100644 Assets/Features/VoxelWorldNavMesh.meta create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime.meta create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef.meta create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs.meta create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs create mode 100644 Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs.meta create mode 100644 Assets/Scripts/VoxelWorld.meta create mode 100644 Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs create mode 100644 Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs.meta create mode 100644 Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs create mode 100644 Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs.meta diff --git a/Assets/Features/VoxelWorld/Contracts.meta b/Assets/Features/VoxelWorld/Contracts.meta new file mode 100644 index 00000000..1e8abed8 --- /dev/null +++ b/Assets/Features/VoxelWorld/Contracts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9b8ddf3935be4c6da0df53dfe0792909 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs b/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs new file mode 100644 index 00000000..f4e314e1 --- /dev/null +++ b/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs @@ -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 results); + bool TryGetChunkNavSourceSnapshot(Vector2Int coord, out ChunkNavSourceSnapshot snapshot); + } + + public interface IWorldInterestReader + { + int InterestVersion { get; } + void GetInterestPoints(List 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; } + } +} diff --git a/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs.meta b/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs.meta new file mode 100644 index 00000000..d8b4fdc9 --- /dev/null +++ b/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55dd8e2a1b2d458aa96895a54d53e6ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef b/Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef new file mode 100644 index 00000000..a34845d1 --- /dev/null +++ b/Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef @@ -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 +} diff --git a/Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef.meta b/Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef.meta new file mode 100644 index 00000000..2739127c --- /dev/null +++ b/Assets/Features/VoxelWorld/Contracts/VoxelWorld.Contracts.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 96b902ea5b554a1b8a9e0c29e03118f2 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab b/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab index f9bd641e..860f0f97 100644 --- a/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab +++ b/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab @@ -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 diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorld.Runtime.asmdef b/Assets/Features/VoxelWorld/Runtime/VoxelWorld.Runtime.asmdef index 6e69c3c6..3d213bc9 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorld.Runtime.asmdef +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorld.Runtime.asmdef @@ -2,7 +2,9 @@ "name": "VoxelWorld.Runtime", "rootNamespace": "InfiniteWorld.VoxelWorld", "references": [ - "UniTask" + "UniTask", + "VoxelWorld.Contracts", + "MessagePipe" ], "includePlatforms": [], "excludePlatforms": [], diff --git a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs index fb0e468c..25a9216d 100644 --- a/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs +++ b/Assets/Features/VoxelWorld/Runtime/VoxelWorldGenerator.cs @@ -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 chunkNavGeometryReadyPublisher; + private IPublisher chunkNavGeometryRemovedPublisher; + private IPublisher 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 chunkNavGeometryReadyPublisher, + IPublisher chunkNavGeometryRemovedPublisher, + IPublisher 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 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 results) + { + if (results == null) + { + throw new ArgumentNullException(nameof(results)); + } + + foreach (KeyValuePair 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 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)) diff --git a/Assets/Features/VoxelWorldNavMesh.meta b/Assets/Features/VoxelWorldNavMesh.meta new file mode 100644 index 00000000..9195b809 --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 814b46557fef4e36a0cba9242dd1feea +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime.meta b/Assets/Features/VoxelWorldNavMesh/Runtime.meta new file mode 100644 index 00000000..820d7784 --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ce0e93eaf54c45e8bff2ff3770aad24d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef new file mode 100644 index 00000000..f8f8bdee --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef @@ -0,0 +1,19 @@ +{ + "name": "VoxelWorld.NavMesh.Runtime", + "rootNamespace": "InfiniteWorld.VoxelWorld.NavMesh", + "references": [ + "VoxelWorld.Contracts", + "UniTask", + "VContainer", + "MessagePipe" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef.meta b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef.meta new file mode 100644 index 00000000..8aa18549 --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorld.NavMesh.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1c0bf4204de447d69095f9f1fa208e2e +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs new file mode 100644 index 00000000..b2621139 --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs @@ -0,0 +1,16 @@ +using System; +using UnityEngine; + +namespace InfiniteWorld.VoxelWorld.NavMesh +{ + [Serializable] + public sealed class VoxelWorldNavMeshConfig + { + [Min(0)] public int agentTypeId; + [Min(1)] public int navRegionSizeInChunks = 2; + [Min(1)] public int maxNavMeshBuildsPerFrame = 1; + [Min(0f)] public float navBoundsHorizontalPadding = 1f; + [Min(0f)] public float navBoundsVerticalPadding = 2f; + [Min(0)] public int navWarmupRadiusInRegions = 1; + } +} diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs.meta b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs.meta new file mode 100644 index 00000000..5d8e4365 --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 15bfc8bcd2594a3193c1bcd7eff3e770 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs new file mode 100644 index 00000000..8552970b --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs @@ -0,0 +1,551 @@ +using System; +using System.Collections.Generic; +using InfiniteWorld.VoxelWorld.Contracts; +using MessagePipe; +using UnityEngine; +using UnityEngine.AI; +using VContainer.Unity; +using UnityNavMesh = UnityEngine.AI.NavMesh; +using UnityNavMeshBuilder = UnityEngine.AI.NavMeshBuilder; + +namespace InfiniteWorld.VoxelWorld.NavMesh +{ + public sealed class VoxelWorldNavMeshService : IStartable, ITickable, IDisposable + { + private readonly IChunkNavSourceReader chunkNavSourceReader; + private readonly IWorldInterestReader worldInterestReader; + private readonly ISubscriber chunkReadySubscriber; + private readonly ISubscriber chunkRemovedSubscriber; + private readonly ISubscriber worldInterestChangedSubscriber; + private readonly VoxelWorldNavMeshConfig config; + private readonly Dictionary navRegions = new Dictionary(); + private readonly Queue dirtyNavRegions = new Queue(); + private readonly HashSet queuedNavRegions = new HashSet(); + private readonly List loadedChunkCoords = new List(64); + private readonly List interestPoints = new List(4); + private readonly List dirtyRegionCandidates = new List(16); + private readonly List buildSources = new List(64); + private readonly HashSet currentInterestRegions = new HashSet(); + private readonly HashSet previousInterestRegions = new HashSet(); + private readonly List subscriptions = new List(3); + + private Vector2Int? activeBuildRegion; + + public VoxelWorldNavMeshService( + IChunkNavSourceReader chunkNavSourceReader, + IWorldInterestReader worldInterestReader, + ISubscriber chunkReadySubscriber, + ISubscriber chunkRemovedSubscriber, + ISubscriber worldInterestChangedSubscriber, + VoxelWorldNavMeshConfig config) + { + this.chunkNavSourceReader = chunkNavSourceReader; + this.worldInterestReader = worldInterestReader; + this.chunkReadySubscriber = chunkReadySubscriber; + this.chunkRemovedSubscriber = chunkRemovedSubscriber; + this.worldInterestChangedSubscriber = worldInterestChangedSubscriber; + this.config = config ?? new VoxelWorldNavMeshConfig(); + } + + public void Start() + { + subscriptions.Add(chunkReadySubscriber.Subscribe(OnChunkNavGeometryReady)); + subscriptions.Add(chunkRemovedSubscriber.Subscribe(OnChunkNavGeometryRemoved)); + subscriptions.Add(worldInterestChangedSubscriber.Subscribe(OnWorldInterestChanged)); + + RefreshInterestPoints(); + + loadedChunkCoords.Clear(); + chunkNavSourceReader.GetLoadedChunkCoords(loadedChunkCoords); + for (int i = 0; i < loadedChunkCoords.Count; i++) + { + MarkDirtyForChunk(loadedChunkCoords[i]); + } + + MarkWarmupRegionsDirty(); + } + + public void Tick() + { + RefreshInterestPoints(); + CompleteFinishedBuild(); + + int startedBuilds = 0; + int maxBuilds = Mathf.Max(1, config.maxNavMeshBuildsPerFrame); + while (startedBuilds < maxBuilds) + { + if (activeBuildRegion.HasValue || dirtyNavRegions.Count == 0) + { + break; + } + + Vector2Int regionCoord = DequeueBestDirtyRegion(); + if (!TryStartRegionBuild(regionCoord)) + { + startedBuilds++; + continue; + } + + startedBuilds++; + } + } + + public void Dispose() + { + for (int i = 0; i < subscriptions.Count; i++) + { + subscriptions[i]?.Dispose(); + } + + subscriptions.Clear(); + + foreach (KeyValuePair pair in navRegions) + { + pair.Value.Dispose(); + } + + navRegions.Clear(); + queuedNavRegions.Clear(); + dirtyNavRegions.Clear(); + currentInterestRegions.Clear(); + previousInterestRegions.Clear(); + activeBuildRegion = null; + } + + private void OnChunkNavGeometryReady(ChunkNavGeometryReadyMessage message) + { + MarkDirtyForChunk(message.Coord); + } + + private void OnChunkNavGeometryRemoved(ChunkNavGeometryRemovedMessage message) + { + MarkDirtyForChunk(message.Coord); + } + + private void OnWorldInterestChanged(WorldInterestChangedMessage message) + { + RefreshInterestPoints(); + MarkWarmupRegionsDirty(); + } + + private void RefreshInterestPoints() + { + interestPoints.Clear(); + worldInterestReader.GetInterestPoints(interestPoints); + + previousInterestRegions.Clear(); + foreach (Vector2Int region in currentInterestRegions) + { + previousInterestRegions.Add(region); + } + + currentInterestRegions.Clear(); + for (int i = 0; i < interestPoints.Count; i++) + { + currentInterestRegions.Add(ChunkToRegion(WorldToChunk(interestPoints[i].Position))); + } + + if (!AreSetsEqual(previousInterestRegions, currentInterestRegions)) + { + MarkWarmupRegionsDirty(); + } + } + + private void MarkWarmupRegionsDirty() + { + int radius = Mathf.Max(0, config.navWarmupRadiusInRegions); + foreach (Vector2Int region in currentInterestRegions) + { + for (int y = -radius; y <= radius; y++) + { + for (int x = -radius; x <= radius; x++) + { + EnqueueDirtyRegion(new Vector2Int(region.x + x, region.y + y)); + } + } + } + } + + private void MarkDirtyForChunk(Vector2Int chunkCoord) + { + int regionSize = Mathf.Max(1, config.navRegionSizeInChunks); + Vector2Int regionCoord = ChunkToRegion(chunkCoord); + EnqueueDirtyRegion(regionCoord); + + int localX = PositiveModulo(chunkCoord.x, regionSize); + int localY = PositiveModulo(chunkCoord.y, regionSize); + if (localX == 0) + { + EnqueueDirtyRegion(regionCoord + Vector2Int.left); + } + + if (localX == regionSize - 1) + { + EnqueueDirtyRegion(regionCoord + Vector2Int.right); + } + + if (localY == 0) + { + EnqueueDirtyRegion(regionCoord + Vector2Int.down); + } + + if (localY == regionSize - 1) + { + EnqueueDirtyRegion(regionCoord + Vector2Int.up); + } + } + + private void EnqueueDirtyRegion(Vector2Int regionCoord) + { + if (!queuedNavRegions.Add(regionCoord)) + { + if (activeBuildRegion.HasValue && activeBuildRegion.Value == regionCoord && navRegions.TryGetValue(regionCoord, out NavRegionRuntime activeRegion)) + { + activeRegion.BuildRequestedWhileRunning = true; + } + + return; + } + + dirtyNavRegions.Enqueue(regionCoord); + } + + private Vector2Int DequeueBestDirtyRegion() + { + dirtyRegionCandidates.Clear(); + while (dirtyNavRegions.Count > 0) + { + dirtyRegionCandidates.Add(dirtyNavRegions.Dequeue()); + } + + int bestIndex = 0; + float bestScore = float.MaxValue; + for (int i = 0; i < dirtyRegionCandidates.Count; i++) + { + float score = GetRegionPriorityScore(dirtyRegionCandidates[i]); + if (score < bestScore) + { + bestScore = score; + bestIndex = i; + } + } + + Vector2Int best = dirtyRegionCandidates[bestIndex]; + queuedNavRegions.Remove(best); + + for (int i = 0; i < dirtyRegionCandidates.Count; i++) + { + if (i == bestIndex) + { + continue; + } + + dirtyNavRegions.Enqueue(dirtyRegionCandidates[i]); + } + + dirtyRegionCandidates.Clear(); + return best; + } + + private float GetRegionPriorityScore(Vector2Int regionCoord) + { + if (interestPoints.Count == 0) + { + return 0f; + } + + Vector3 regionCenter = GetRegionCenter(regionCoord); + float bestDistance = float.MaxValue; + for (int i = 0; i < interestPoints.Count; i++) + { + float priority = Mathf.Max(0.01f, interestPoints[i].Priority); + float distance = Vector3.SqrMagnitude(regionCenter - interestPoints[i].Position) / priority; + if (distance < bestDistance) + { + bestDistance = distance; + } + } + + return bestDistance; + } + + private bool TryStartRegionBuild(Vector2Int regionCoord) + { + buildSources.Clear(); + bool hasCoreChunk = CollectBuildSources(regionCoord, buildSources); + if (!hasCoreChunk || buildSources.Count == 0) + { + RemoveRegion(regionCoord); + return false; + } + + Bounds buildBounds = CalculateBounds(buildSources); + ExpandBounds(ref buildBounds, config.navBoundsHorizontalPadding, config.navBoundsVerticalPadding); + + NavRegionRuntime region = GetOrCreateRegion(regionCoord); + region.BuildRequestedWhileRunning = false; + region.BuildBounds = buildBounds; + + if (region.NavMeshData == null) + { + region.NavMeshData = new NavMeshData(config.agentTypeId); + } + + if (!region.Instance.valid) + { + region.Instance = UnityNavMesh.AddNavMeshData(region.NavMeshData); + } + + NavMeshBuildSettings buildSettings = UnityNavMesh.GetSettingsByID(config.agentTypeId); + region.ActiveBuild = UnityNavMeshBuilder.UpdateNavMeshDataAsync(region.NavMeshData, buildSettings, buildSources, buildBounds); + activeBuildRegion = regionCoord; + return true; + } + + private bool CollectBuildSources(Vector2Int regionCoord, List results) + { + int regionSize = Mathf.Max(1, config.navRegionSizeInChunks); + int baseChunkX = regionCoord.x * regionSize; + int baseChunkY = regionCoord.y * regionSize; + bool hasCoreChunk = false; + + for (int y = -1; y <= regionSize; y++) + { + for (int x = -1; x <= regionSize; x++) + { + Vector2Int chunkCoord = new Vector2Int(baseChunkX + x, baseChunkY + y); + if (!chunkNavSourceReader.TryGetChunkNavSourceSnapshot(chunkCoord, out ChunkNavSourceSnapshot snapshot) || snapshot.Sources == null || snapshot.Sources.Length == 0) + { + continue; + } + + if (x >= 0 && x < regionSize && y >= 0 && y < regionSize) + { + hasCoreChunk = true; + } + + AppendBuildSources(snapshot.Sources, results); + } + } + + return hasCoreChunk; + } + + private static void AppendBuildSources(ChunkNavBuildSourceDescriptor[] descriptors, List results) + { + for (int i = 0; i < descriptors.Length; i++) + { + ChunkNavBuildSourceDescriptor descriptor = descriptors[i]; + if (descriptor.Shape == NavMeshBuildSourceShape.Mesh && descriptor.Mesh == null) + { + continue; + } + + NavMeshBuildSource source = new NavMeshBuildSource + { + area = descriptor.Area, + shape = descriptor.Shape, + transform = descriptor.Transform, + size = descriptor.Size, + sourceObject = descriptor.Shape == NavMeshBuildSourceShape.Mesh ? descriptor.Mesh : null + }; + + results.Add(source); + } + } + + private void CompleteFinishedBuild() + { + if (!activeBuildRegion.HasValue) + { + return; + } + + if (!navRegions.TryGetValue(activeBuildRegion.Value, out NavRegionRuntime region)) + { + activeBuildRegion = null; + return; + } + + if (region.ActiveBuild != null && !region.ActiveBuild.isDone) + { + return; + } + + region.ActiveBuild = null; + Vector2Int completedRegion = activeBuildRegion.Value; + activeBuildRegion = null; + + if (region.BuildRequestedWhileRunning) + { + region.BuildRequestedWhileRunning = false; + EnqueueDirtyRegion(completedRegion); + } + } + + private NavRegionRuntime GetOrCreateRegion(Vector2Int regionCoord) + { + if (!navRegions.TryGetValue(regionCoord, out NavRegionRuntime region)) + { + region = new NavRegionRuntime(); + navRegions.Add(regionCoord, region); + } + + return region; + } + + private void RemoveRegion(Vector2Int regionCoord) + { + if (!navRegions.TryGetValue(regionCoord, out NavRegionRuntime region)) + { + return; + } + + if (activeBuildRegion.HasValue && activeBuildRegion.Value == regionCoord) + { + activeBuildRegion = null; + } + + region.Dispose(); + navRegions.Remove(regionCoord); + } + + private static Bounds CalculateBounds(List sources) + { + Bounds bounds = GetSourceBounds(sources[0]); + for (int i = 1; i < sources.Count; i++) + { + bounds.Encapsulate(GetSourceBounds(sources[i])); + } + + return bounds; + } + + private static Bounds GetSourceBounds(NavMeshBuildSource source) + { + if (source.shape == NavMeshBuildSourceShape.Box) + { + return TransformBounds(source.transform, new Bounds(Vector3.zero, source.size)); + } + + Mesh mesh = source.sourceObject as Mesh; + if (mesh != null) + { + return TransformBounds(source.transform, mesh.bounds); + } + + return new Bounds(source.transform.GetColumn(3), Vector3.zero); + } + + private static Bounds TransformBounds(Matrix4x4 matrix, Bounds localBounds) + { + Vector3 center = localBounds.center; + Vector3 extents = localBounds.extents; + + Vector3[] corners = + { + new Vector3(center.x - extents.x, center.y - extents.y, center.z - extents.z), + new Vector3(center.x - extents.x, center.y - extents.y, center.z + extents.z), + new Vector3(center.x - extents.x, center.y + extents.y, center.z - extents.z), + new Vector3(center.x - extents.x, center.y + extents.y, center.z + extents.z), + new Vector3(center.x + extents.x, center.y - extents.y, center.z - extents.z), + new Vector3(center.x + extents.x, center.y - extents.y, center.z + extents.z), + new Vector3(center.x + extents.x, center.y + extents.y, center.z - extents.z), + new Vector3(center.x + extents.x, center.y + extents.y, center.z + extents.z) + }; + + Bounds worldBounds = new Bounds(matrix.MultiplyPoint3x4(corners[0]), Vector3.zero); + for (int i = 1; i < corners.Length; i++) + { + worldBounds.Encapsulate(matrix.MultiplyPoint3x4(corners[i])); + } + + return worldBounds; + } + + private static void ExpandBounds(ref Bounds bounds, float horizontalPadding, float verticalPadding) + { + Vector3 size = bounds.size; + size.x = Mathf.Max(size.x + horizontalPadding * 2f, 0.1f); + size.z = Mathf.Max(size.z + horizontalPadding * 2f, 0.1f); + size.y = Mathf.Max(size.y + verticalPadding * 2f, 0.1f); + bounds.size = size; + } + + private Vector2Int WorldToChunk(Vector3 position) + { + float chunkSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); + return new Vector2Int( + Mathf.FloorToInt(position.x / chunkSize), + Mathf.FloorToInt(position.z / chunkSize)); + } + + private Vector2Int ChunkToRegion(Vector2Int chunkCoord) + { + int size = Mathf.Max(1, config.navRegionSizeInChunks); + return new Vector2Int( + Mathf.FloorToInt(chunkCoord.x / (float)size), + Mathf.FloorToInt(chunkCoord.y / (float)size)); + } + + private Vector3 GetRegionCenter(Vector2Int regionCoord) + { + float chunkSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); + float regionSize = Mathf.Max(1, config.navRegionSizeInChunks) * chunkSize; + return new Vector3( + (regionCoord.x + 0.5f) * regionSize, + 0f, + (regionCoord.y + 0.5f) * regionSize); + } + + private static bool AreSetsEqual(HashSet left, HashSet right) + { + if (left.Count != right.Count) + { + return false; + } + + foreach (Vector2Int value in left) + { + if (!right.Contains(value)) + { + return false; + } + } + + return true; + } + + private static int PositiveModulo(int value, int modulus) + { + int result = value % modulus; + return result < 0 ? result + modulus : result; + } + + private sealed class NavRegionRuntime : IDisposable + { + public NavMeshData NavMeshData; + public NavMeshDataInstance Instance; + public AsyncOperation ActiveBuild; + public bool BuildRequestedWhileRunning; + public Bounds BuildBounds; + + public void Dispose() + { + if (ActiveBuild != null && !ActiveBuild.isDone && NavMeshData != null) + { + UnityNavMeshBuilder.Cancel(NavMeshData); + } + + if (Instance.valid) + { + UnityNavMesh.RemoveNavMeshData(Instance); + Instance = default; + } + + ActiveBuild = null; + NavMeshData = null; + } + } + } +} diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs.meta b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs.meta new file mode 100644 index 00000000..77dd75ab --- /dev/null +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1f16cad74f034aa899a965d1ff0ef8aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Players/CameraFollow.cs b/Assets/Scripts/Players/CameraFollow.cs index ee50cad0..51329b86 100644 --- a/Assets/Scripts/Players/CameraFollow.cs +++ b/Assets/Scripts/Players/CameraFollow.cs @@ -14,6 +14,8 @@ namespace Players private float _mouseOrbitAngle; + public Transform Target => _target != null ? _target : transform; + public override void OnStartClient() { base.OnStartClient(); diff --git a/Assets/Scripts/VoxelWorld.meta b/Assets/Scripts/VoxelWorld.meta new file mode 100644 index 00000000..b44f908e --- /dev/null +++ b/Assets/Scripts/VoxelWorld.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3c4e0cf9d3254f8bbb320e52c9a67bd0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs b/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs new file mode 100644 index 00000000..5e2ae99b --- /dev/null +++ b/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs @@ -0,0 +1,51 @@ +using InfiniteWorld.VoxelWorld; +using InfiniteWorld.VoxelWorld.Contracts; +using InfiniteWorld.VoxelWorld.NavMesh; +using MessagePipe; +using UnityEngine; +using VContainer; +using VContainer.Unity; + +namespace VoxelWorldScene +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(VoxelWorldGenerator))] + public sealed class VoxelWorldNavMeshLifetimeScope : LifetimeScope + { + [SerializeField] private bool enableRuntimeNavMesh = true; + [SerializeField] private VoxelWorldGenerator worldGenerator; + [SerializeField] private VoxelWorldNavMeshConfig config = new VoxelWorldNavMeshConfig(); + + protected override void Configure(IContainerBuilder builder) + { + if (!enableRuntimeNavMesh) + { + return; + } + + if (worldGenerator == null) + { + worldGenerator = GetComponent(); + } + + builder.RegisterMessagePipe(); + builder.RegisterInstance(config); + builder.RegisterInstance(worldGenerator).As().As().AsSelf(); + builder.RegisterEntryPoint(); + builder.RegisterBuildCallback(ResolvePublishers); + } + + private void ResolvePublishers(IObjectResolver resolver) + { + if (!enableRuntimeNavMesh || worldGenerator == null) + { + return; + } + + worldGenerator.BindWorldContracts( + resolver.Resolve>(), + resolver.Resolve>(), + resolver.Resolve>()); + } + } +} diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs.meta b/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs.meta new file mode 100644 index 00000000..eb6a6a27 --- /dev/null +++ b/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2dfd0b7ddf3a419f91ce891210f85d4b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs b/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs new file mode 100644 index 00000000..dad7aa55 --- /dev/null +++ b/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs @@ -0,0 +1,75 @@ +using InfiniteWorld.VoxelWorld; +using Players; +using UnityEngine; + +namespace VoxelWorldScene +{ + [DisallowMultipleComponent] + [RequireComponent(typeof(VoxelWorldGenerator))] + public sealed class VoxelWorldPlayerStreamTargetBinding : MonoBehaviour + { + [SerializeField] private VoxelWorldGenerator worldGenerator; + [SerializeField] private Transform explicitStreamTarget; + + private Transform currentStreamTarget; + + private void Awake() + { + if (worldGenerator == null) + { + worldGenerator = GetComponent(); + } + + ApplyResolvedTarget(ResolveTarget()); + } + + private void Update() + { + ApplyResolvedTarget(ResolveTarget()); + } + + private void OnDisable() + { + ApplyResolvedTarget(null); + } + + private Transform ResolveTarget() + { + if (explicitStreamTarget != null) + { + return explicitStreamTarget; + } + + if (currentStreamTarget != null) + { + return currentStreamTarget; + } + + CameraFollow[] cameraFollows = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + for (int i = 0; i < cameraFollows.Length; i++) + { + CameraFollow follow = cameraFollows[i]; + if (follow != null && follow.IsOwner) + { + return follow.Target; + } + } + + return null; + } + + private void ApplyResolvedTarget(Transform resolvedTarget) + { + if (currentStreamTarget == resolvedTarget) + { + return; + } + + currentStreamTarget = resolvedTarget; + if (worldGenerator != null) + { + worldGenerator.SetStreamTarget(currentStreamTarget); + } + } + } +} diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs.meta b/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs.meta new file mode 100644 index 00000000..faf18600 --- /dev/null +++ b/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c52a16bd6e44739b6bb1b4471a7a5a9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: