From 0b380def786b6d29aebec80fbdab52f5ea352c2a Mon Sep 17 00:00:00 2001 From: Alexander Borisov Date: Wed, 8 Apr 2026 13:52:00 +0300 Subject: [PATCH] refactor nav coverage into clustered windows Replace region-based runtime pathing with interest-cluster coverage windows so active nav areas stay contiguous and spawn anchors participate in initial coverage. --- .../Contracts/NavMeshWorldContracts.cs | 32 +- .../VoxelWorld/Prefabs/VoxelWorld.prefab | 8 +- .../Scenes/VoxelWorldTestScene.unity | 14 + .../Runtime/VoxelWorldNavMeshConfig.cs | 8 +- .../Runtime/VoxelWorldNavMeshService.cs | 723 +++++++++++++----- .../VoxelWorld/SceneWorldInterestReader.cs | 59 ++ .../SceneWorldInterestReader.cs.meta | 11 + .../VoxelWorldNavMeshLifetimeScope.cs | 6 +- .../VoxelWorldPlayerStreamTargetBinding.cs | 15 +- .../VoxelWorld/VoxelWorldSpawnAnchor.cs | 12 + .../VoxelWorld/VoxelWorldSpawnAnchor.cs.meta | 11 + 11 files changed, 680 insertions(+), 219 deletions(-) create mode 100644 Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs create mode 100644 Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs.meta create mode 100644 Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs create mode 100644 Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs.meta diff --git a/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs b/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs index f4e314e1..4715a285 100644 --- a/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs +++ b/Assets/Features/VoxelWorld/Contracts/NavMeshWorldContracts.cs @@ -17,6 +17,12 @@ namespace InfiniteWorld.VoxelWorld.Contracts void GetInterestPoints(List results); } + public interface INavCoverageReader + { + bool IsPositionCovered(Vector3 worldPosition); + void GetCoverageWindows(List results); + } + public readonly struct ChunkNavSourceSnapshot { public ChunkNavSourceSnapshot(Vector2Int coord, int version, ChunkNavBuildSourceDescriptor[] sources) @@ -77,7 +83,31 @@ namespace InfiniteWorld.VoxelWorld.Contracts { PlayerActor = 0, ActiveNpc = 1, - Other = 2 + SpawnAnchor = 2, + Other = 3 + } + + public readonly struct NavCoverageWindowSnapshot + { + public NavCoverageWindowSnapshot(int id, Bounds bounds, NavCoverageState state, int interestCount) + { + Id = id; + Bounds = bounds; + State = state; + InterestCount = interestCount; + } + + public int Id { get; } + public Bounds Bounds { get; } + public NavCoverageState State { get; } + public int InterestCount { get; } + } + + public enum NavCoverageState + { + Pending = 0, + Building = 1, + Ready = 2 } public readonly struct ChunkNavGeometryReadyMessage diff --git a/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab b/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab index 860f0f97..72319660 100644 --- a/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab +++ b/Assets/Features/VoxelWorld/Prefabs/VoxelWorld.prefab @@ -83,8 +83,12 @@ MonoBehaviour: worldGenerator: {fileID: 2927522923773808063} config: agentTypeId: 0 - navRegionSizeInChunks: 2 maxNavMeshBuildsPerFrame: 1 navBoundsHorizontalPadding: 1 navBoundsVerticalPadding: 2 - navWarmupRadiusInRegions: 1 + maxActiveCoverageWindows: 3 + clusterMergeDistanceInChunks: 4 + coveragePaddingInChunks: 2 + coverageQuantizationInChunks: 1 + minCoverageWindowSizeInChunks: 4 + chunkCollectionMarginInChunks: 1 diff --git a/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity b/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity index a8028415..0f400d8f 100644 --- a/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity +++ b/Assets/Features/VoxelWorld/Scenes/VoxelWorldTestScene.unity @@ -255,6 +255,7 @@ GameObject: serializedVersion: 6 m_Component: - component: {fileID: 171707223} + - component: {fileID: 171707224} m_Layer: 0 m_Name: SpawnPoint m_TagString: Untagged @@ -277,6 +278,19 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &171707224 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 171707222} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 7a0a7758ae4541b39ed0b5d1fe912869, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::VoxelWorldScene.VoxelWorldSpawnAnchor + priority: 2 --- !u!1001 &1165873058 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs index b2621139..a81fd35a 100644 --- a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshConfig.cs @@ -7,10 +7,14 @@ namespace InfiniteWorld.VoxelWorld.NavMesh 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; + [Min(1)] public int maxActiveCoverageWindows = 3; + [Min(0f)] public float clusterMergeDistanceInChunks = 4f; + [Min(0f)] public float coveragePaddingInChunks = 2f; + [Min(0.25f)] public float coverageQuantizationInChunks = 1f; + [Min(1f)] public float minCoverageWindowSizeInChunks = 4f; + [Min(0)] public int chunkCollectionMarginInChunks = 1; } } diff --git a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs index 8552970b..50412ec4 100644 --- a/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs +++ b/Assets/Features/VoxelWorldNavMesh/Runtime/VoxelWorldNavMeshService.cs @@ -10,7 +10,7 @@ using UnityNavMeshBuilder = UnityEngine.AI.NavMeshBuilder; namespace InfiniteWorld.VoxelWorld.NavMesh { - public sealed class VoxelWorldNavMeshService : IStartable, ITickable, IDisposable + public sealed class VoxelWorldNavMeshService : IStartable, ITickable, IDisposable, INavCoverageReader { private readonly IChunkNavSourceReader chunkNavSourceReader; private readonly IWorldInterestReader worldInterestReader; @@ -18,18 +18,20 @@ namespace InfiniteWorld.VoxelWorld.NavMesh 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 Dictionary coverageWindows = new Dictionary(); + private readonly Queue dirtyCoverageWindowIds = new Queue(); + private readonly HashSet queuedCoverageWindowIds = new HashSet(); + private readonly List dirtyCoverageWindowCandidates = new List(16); + private readonly List interestPoints = new List(8); + private readonly List loadedChunkCoords = new List(128); + private readonly List buildSources = new List(256); + private readonly List coverageWindowSnapshots = new List(8); + private readonly List desiredCoverageWindows = new List(8); + private readonly List clusterAccumulators = new List(8); private readonly List subscriptions = new List(3); - private Vector2Int? activeBuildRegion; + private int nextCoverageWindowId = 1; + private int? activeBuildWindowId; public VoxelWorldNavMeshService( IChunkNavSourceReader chunkNavSourceReader, @@ -54,33 +56,27 @@ namespace InfiniteWorld.VoxelWorld.NavMesh subscriptions.Add(worldInterestChangedSubscriber.Subscribe(OnWorldInterestChanged)); RefreshInterestPoints(); - - loadedChunkCoords.Clear(); - chunkNavSourceReader.GetLoadedChunkCoords(loadedChunkCoords); - for (int i = 0; i < loadedChunkCoords.Count; i++) - { - MarkDirtyForChunk(loadedChunkCoords[i]); - } - - MarkWarmupRegionsDirty(); + SyncCoverageWindows(); + MarkAllCoverageWindowsDirty(); } public void Tick() { RefreshInterestPoints(); + SyncCoverageWindows(); CompleteFinishedBuild(); int startedBuilds = 0; int maxBuilds = Mathf.Max(1, config.maxNavMeshBuildsPerFrame); while (startedBuilds < maxBuilds) { - if (activeBuildRegion.HasValue || dirtyNavRegions.Count == 0) + if (activeBuildWindowId.HasValue || dirtyCoverageWindowIds.Count == 0) { break; } - Vector2Int regionCoord = DequeueBestDirtyRegion(); - if (!TryStartRegionBuild(regionCoord)) + int windowId = DequeueBestDirtyCoverageWindow(); + if (!TryStartCoverageBuild(windowId)) { startedBuilds++; continue; @@ -90,6 +86,34 @@ namespace InfiniteWorld.VoxelWorld.NavMesh } } + public bool IsPositionCovered(Vector3 worldPosition) + { + foreach (KeyValuePair pair in coverageWindows) + { + NavCoverageWindowRuntime window = pair.Value; + if (window.State == NavCoverageState.Ready && ContainsXZ(window.CoverageBounds, worldPosition)) + { + return true; + } + } + + return false; + } + + public void GetCoverageWindows(List results) + { + if (results == null) + { + throw new ArgumentNullException(nameof(results)); + } + + foreach (KeyValuePair pair in coverageWindows) + { + NavCoverageWindowRuntime window = pair.Value; + results.Add(new NavCoverageWindowSnapshot(window.Id, window.CoverageBounds, window.State, window.InterestCount)); + } + } + public void Dispose() { for (int i = 0; i < subscriptions.Count; i++) @@ -99,130 +123,315 @@ namespace InfiniteWorld.VoxelWorld.NavMesh subscriptions.Clear(); - foreach (KeyValuePair pair in navRegions) + foreach (KeyValuePair pair in coverageWindows) { pair.Value.Dispose(); } - navRegions.Clear(); - queuedNavRegions.Clear(); - dirtyNavRegions.Clear(); - currentInterestRegions.Clear(); - previousInterestRegions.Clear(); - activeBuildRegion = null; + coverageWindows.Clear(); + queuedCoverageWindowIds.Clear(); + dirtyCoverageWindowIds.Clear(); + desiredCoverageWindows.Clear(); + clusterAccumulators.Clear(); + coverageWindowSnapshots.Clear(); + activeBuildWindowId = null; } private void OnChunkNavGeometryReady(ChunkNavGeometryReadyMessage message) { - MarkDirtyForChunk(message.Coord); + MarkCoverageWindowsDirtyForChunk(message.Coord); } private void OnChunkNavGeometryRemoved(ChunkNavGeometryRemovedMessage message) { - MarkDirtyForChunk(message.Coord); + MarkCoverageWindowsDirtyForChunk(message.Coord); } private void OnWorldInterestChanged(WorldInterestChangedMessage message) { RefreshInterestPoints(); - MarkWarmupRegionsDirty(); + SyncCoverageWindows(); + MarkAllCoverageWindowsDirty(); } private void RefreshInterestPoints() { interestPoints.Clear(); worldInterestReader.GetInterestPoints(interestPoints); + } - previousInterestRegions.Clear(); - foreach (Vector2Int region in currentInterestRegions) + private void SyncCoverageWindows() + { + BuildDesiredCoverageWindows(); + + foreach (KeyValuePair pair in coverageWindows) { - previousInterestRegions.Add(region); + pair.Value.MatchedThisFrame = false; } - currentInterestRegions.Clear(); + for (int i = 0; i < desiredCoverageWindows.Count; i++) + { + DesiredCoverageWindow desiredWindow = desiredCoverageWindows[i]; + NavCoverageWindowRuntime runtime = FindBestMatchingCoverageWindow(desiredWindow); + if (runtime == null) + { + runtime = new NavCoverageWindowRuntime(nextCoverageWindowId++, desiredWindow.CoverageBounds, desiredWindow.Priority, desiredWindow.InterestCount); + runtime.MatchedThisFrame = true; + coverageWindows.Add(runtime.Id, runtime); + EnqueueDirtyCoverageWindow(runtime.Id); + continue; + } + + runtime.MatchedThisFrame = true; + runtime.Priority = desiredWindow.Priority; + runtime.InterestCount = desiredWindow.InterestCount; + + if (!BoundsApproximatelyEqual(runtime.CoverageBounds, desiredWindow.CoverageBounds)) + { + runtime.CoverageBounds = desiredWindow.CoverageBounds; + runtime.State = NavCoverageState.Pending; + EnqueueDirtyCoverageWindow(runtime.Id); + } + } + + coverageWindowSnapshots.Clear(); + foreach (KeyValuePair pair in coverageWindows) + { + if (pair.Value.MatchedThisFrame) + { + coverageWindowSnapshots.Add(new NavCoverageWindowSnapshot(pair.Value.Id, pair.Value.CoverageBounds, pair.Value.State, pair.Value.InterestCount)); + } + } + + RemoveUnmatchedCoverageWindows(); + } + + private void BuildDesiredCoverageWindows() + { + desiredCoverageWindows.Clear(); + clusterAccumulators.Clear(); + + if (interestPoints.Count == 0) + { + return; + } + + float chunkWorldSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); + float mergeDistance = Mathf.Max(0f, config.clusterMergeDistanceInChunks) * chunkWorldSize; + float quantizationStep = Mathf.Max(0.25f, config.coverageQuantizationInChunks) * chunkWorldSize; + float padding = Mathf.Max(0f, config.coveragePaddingInChunks) * chunkWorldSize; + float minWindowSize = Mathf.Max(1f, config.minCoverageWindowSizeInChunks) * chunkWorldSize; + for (int i = 0; i < interestPoints.Count; i++) { - currentInterestRegions.Add(ChunkToRegion(WorldToChunk(interestPoints[i].Position))); + WorldInterestPoint point = interestPoints[i]; + int bestClusterIndex = -1; + float bestDistance = float.MaxValue; + + for (int clusterIndex = 0; clusterIndex < clusterAccumulators.Count; clusterIndex++) + { + float distance = DistanceToBoundsXZ(clusterAccumulators[clusterIndex].RawBounds, point.Position); + if (distance <= mergeDistance && distance < bestDistance) + { + bestDistance = distance; + bestClusterIndex = clusterIndex; + } + } + + if (bestClusterIndex >= 0) + { + ClusterAccumulator cluster = clusterAccumulators[bestClusterIndex]; + cluster.Add(point); + clusterAccumulators[bestClusterIndex] = cluster; + } + else + { + clusterAccumulators.Add(new ClusterAccumulator(point)); + } } - if (!AreSetsEqual(previousInterestRegions, currentInterestRegions)) + MergeNearbyClusters(mergeDistance); + + for (int i = 0; i < clusterAccumulators.Count; i++) { - MarkWarmupRegionsDirty(); + ClusterAccumulator cluster = clusterAccumulators[i]; + Bounds coverageBounds = CreateQuantizedCoverageBounds(cluster.RawBounds, padding, minWindowSize, quantizationStep); + desiredCoverageWindows.Add(new DesiredCoverageWindow(coverageBounds, cluster.Priority, cluster.InterestCount)); + } + + desiredCoverageWindows.Sort((left, right) => + { + int priorityCompare = right.Priority.CompareTo(left.Priority); + if (priorityCompare != 0) + { + return priorityCompare; + } + + int interestCompare = right.InterestCount.CompareTo(left.InterestCount); + if (interestCompare != 0) + { + return interestCompare; + } + + return left.CoverageBounds.center.sqrMagnitude.CompareTo(right.CoverageBounds.center.sqrMagnitude); + }); + + int maxWindows = Mathf.Max(1, config.maxActiveCoverageWindows); + if (desiredCoverageWindows.Count > maxWindows) + { + desiredCoverageWindows.RemoveRange(maxWindows, desiredCoverageWindows.Count - maxWindows); } } - private void MarkWarmupRegionsDirty() + private void MergeNearbyClusters(float mergeDistance) { - int radius = Mathf.Max(0, config.navWarmupRadiusInRegions); - foreach (Vector2Int region in currentInterestRegions) + if (clusterAccumulators.Count < 2) { - for (int y = -radius; y <= radius; y++) + return; + } + + bool merged; + do + { + merged = false; + for (int i = 0; i < clusterAccumulators.Count; i++) { - for (int x = -radius; x <= radius; x++) + for (int j = i + 1; j < clusterAccumulators.Count; j++) { - EnqueueDirtyRegion(new Vector2Int(region.x + x, region.y + y)); + if (DistanceBetweenBoundsXZ(clusterAccumulators[i].RawBounds, clusterAccumulators[j].RawBounds) > mergeDistance) + { + continue; + } + + ClusterAccumulator combined = clusterAccumulators[i]; + combined.Merge(clusterAccumulators[j]); + clusterAccumulators[i] = combined; + clusterAccumulators.RemoveAt(j); + merged = true; + break; } + + if (merged) + { + break; + } + } + } + while (merged); + } + + private NavCoverageWindowRuntime FindBestMatchingCoverageWindow(DesiredCoverageWindow desiredWindow) + { + NavCoverageWindowRuntime bestMatch = null; + float bestDistance = float.MaxValue; + float chunkWorldSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); + float matchThreshold = Mathf.Max(config.minCoverageWindowSizeInChunks, config.clusterMergeDistanceInChunks + config.coveragePaddingInChunks) * chunkWorldSize; + + foreach (KeyValuePair pair in coverageWindows) + { + NavCoverageWindowRuntime candidate = pair.Value; + if (candidate.MatchedThisFrame) + { + continue; + } + + float distance = Vector2.Distance( + new Vector2(candidate.CoverageBounds.center.x, candidate.CoverageBounds.center.z), + new Vector2(desiredWindow.CoverageBounds.center.x, desiredWindow.CoverageBounds.center.z)); + + if (distance > matchThreshold || distance >= bestDistance) + { + continue; + } + + bestDistance = distance; + bestMatch = candidate; + } + + return bestMatch; + } + + private void RemoveUnmatchedCoverageWindows() + { + List windowsToRemove = null; + + foreach (KeyValuePair pair in coverageWindows) + { + if (pair.Value.MatchedThisFrame) + { + continue; + } + + windowsToRemove ??= new List(); + windowsToRemove.Add(pair.Key); + } + + if (windowsToRemove == null) + { + return; + } + + for (int i = 0; i < windowsToRemove.Count; i++) + { + RemoveCoverageWindow(windowsToRemove[i]); + } + } + + private void MarkAllCoverageWindowsDirty() + { + foreach (KeyValuePair pair in coverageWindows) + { + EnqueueDirtyCoverageWindow(pair.Key); + } + } + + private void MarkCoverageWindowsDirtyForChunk(Vector2Int chunkCoord) + { + Bounds chunkBounds = ExpandChunkBounds(GetChunkWorldBounds(chunkCoord), Mathf.Max(0, config.chunkCollectionMarginInChunks)); + foreach (KeyValuePair pair in coverageWindows) + { + Bounds invalidationBounds = ExpandCoverageBounds(pair.Value.CoverageBounds, Mathf.Max(0, config.chunkCollectionMarginInChunks)); + if (IntersectsXZ(invalidationBounds, chunkBounds)) + { + EnqueueDirtyCoverageWindow(pair.Key); } } } - private void MarkDirtyForChunk(Vector2Int chunkCoord) + private void EnqueueDirtyCoverageWindow(int windowId) { - 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) + if (!queuedCoverageWindowIds.Add(windowId)) { - 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)) + if (activeBuildWindowId.HasValue && activeBuildWindowId.Value == windowId && coverageWindows.TryGetValue(windowId, out NavCoverageWindowRuntime activeWindow)) { - activeRegion.BuildRequestedWhileRunning = true; + activeWindow.BuildRequestedWhileRunning = true; } return; } - dirtyNavRegions.Enqueue(regionCoord); + dirtyCoverageWindowIds.Enqueue(windowId); } - private Vector2Int DequeueBestDirtyRegion() + private int DequeueBestDirtyCoverageWindow() { - dirtyRegionCandidates.Clear(); - while (dirtyNavRegions.Count > 0) + dirtyCoverageWindowCandidates.Clear(); + while (dirtyCoverageWindowIds.Count > 0) { - dirtyRegionCandidates.Add(dirtyNavRegions.Dequeue()); + dirtyCoverageWindowCandidates.Add(dirtyCoverageWindowIds.Dequeue()); } int bestIndex = 0; float bestScore = float.MaxValue; - for (int i = 0; i < dirtyRegionCandidates.Count; i++) + for (int i = 0; i < dirtyCoverageWindowCandidates.Count; i++) { - float score = GetRegionPriorityScore(dirtyRegionCandidates[i]); + int windowId = dirtyCoverageWindowCandidates[i]; + if (!coverageWindows.TryGetValue(windowId, out NavCoverageWindowRuntime window)) + { + continue; + } + + float score = GetCoveragePriorityScore(window); if (score < bestScore) { bestScore = score; @@ -230,36 +439,37 @@ namespace InfiniteWorld.VoxelWorld.NavMesh } } - Vector2Int best = dirtyRegionCandidates[bestIndex]; - queuedNavRegions.Remove(best); + int bestWindowId = dirtyCoverageWindowCandidates[bestIndex]; + queuedCoverageWindowIds.Remove(bestWindowId); - for (int i = 0; i < dirtyRegionCandidates.Count; i++) + for (int i = 0; i < dirtyCoverageWindowCandidates.Count; i++) { if (i == bestIndex) { continue; } - dirtyNavRegions.Enqueue(dirtyRegionCandidates[i]); + dirtyCoverageWindowIds.Enqueue(dirtyCoverageWindowCandidates[i]); } - dirtyRegionCandidates.Clear(); - return best; + dirtyCoverageWindowCandidates.Clear(); + return bestWindowId; } - private float GetRegionPriorityScore(Vector2Int regionCoord) + private float GetCoveragePriorityScore(NavCoverageWindowRuntime window) { if (interestPoints.Count == 0) { return 0f; } - Vector3 regionCenter = GetRegionCenter(regionCoord); + Vector3 center = window.CoverageBounds.center; 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; + float distance = Vector2.SqrMagnitude( + new Vector2(center.x, center.z) - new Vector2(interestPoints[i].Position.x, interestPoints[i].Position.z)) / priority; if (distance < bestDistance) { bestDistance = distance; @@ -269,66 +479,70 @@ namespace InfiniteWorld.VoxelWorld.NavMesh return bestDistance; } - private bool TryStartRegionBuild(Vector2Int regionCoord) + private bool TryStartCoverageBuild(int windowId) { - buildSources.Clear(); - bool hasCoreChunk = CollectBuildSources(regionCoord, buildSources); - if (!hasCoreChunk || buildSources.Count == 0) + if (!coverageWindows.TryGetValue(windowId, out NavCoverageWindowRuntime window)) { - RemoveRegion(regionCoord); + return false; + } + + buildSources.Clear(); + window.CollectionBounds = ExpandCoverageBounds(window.CoverageBounds, Mathf.Max(0, config.chunkCollectionMarginInChunks)); + + bool hasSources = CollectBuildSources(window.CollectionBounds, buildSources); + if (!hasSources || buildSources.Count == 0) + { + window.State = NavCoverageState.Pending; + RemoveCoverageData(window); return false; } Bounds buildBounds = CalculateBounds(buildSources); ExpandBounds(ref buildBounds, config.navBoundsHorizontalPadding, config.navBoundsVerticalPadding); + window.BuildBounds = buildBounds; + window.BuildRequestedWhileRunning = false; - NavRegionRuntime region = GetOrCreateRegion(regionCoord); - region.BuildRequestedWhileRunning = false; - region.BuildBounds = buildBounds; - - if (region.NavMeshData == null) + if (window.NavMeshData == null) { - region.NavMeshData = new NavMeshData(config.agentTypeId); + window.NavMeshData = new NavMeshData(config.agentTypeId); } - if (!region.Instance.valid) + if (!window.Instance.valid) { - region.Instance = UnityNavMesh.AddNavMeshData(region.NavMeshData); + window.Instance = UnityNavMesh.AddNavMeshData(window.NavMeshData); } NavMeshBuildSettings buildSettings = UnityNavMesh.GetSettingsByID(config.agentTypeId); - region.ActiveBuild = UnityNavMeshBuilder.UpdateNavMeshDataAsync(region.NavMeshData, buildSettings, buildSources, buildBounds); - activeBuildRegion = regionCoord; + window.ActiveBuild = UnityNavMeshBuilder.UpdateNavMeshDataAsync(window.NavMeshData, buildSettings, buildSources, buildBounds); + window.State = NavCoverageState.Building; + activeBuildWindowId = windowId; return true; } - private bool CollectBuildSources(Vector2Int regionCoord, List results) + private bool CollectBuildSources(Bounds coverageBounds, List results) { - int regionSize = Mathf.Max(1, config.navRegionSizeInChunks); - int baseChunkX = regionCoord.x * regionSize; - int baseChunkY = regionCoord.y * regionSize; - bool hasCoreChunk = false; + loadedChunkCoords.Clear(); + chunkNavSourceReader.GetLoadedChunkCoords(loadedChunkCoords); - for (int y = -1; y <= regionSize; y++) + bool hasSources = false; + for (int i = 0; i < loadedChunkCoords.Count; i++) { - for (int x = -1; x <= regionSize; x++) + Vector2Int chunkCoord = loadedChunkCoords[i]; + if (!IntersectsXZ(GetChunkWorldBounds(chunkCoord), coverageBounds)) { - 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); + continue; } + + if (!chunkNavSourceReader.TryGetChunkNavSourceSnapshot(chunkCoord, out ChunkNavSourceSnapshot snapshot) || snapshot.Sources == null || snapshot.Sources.Length == 0) + { + continue; + } + + hasSources = true; + AppendBuildSources(snapshot.Sources, results); } - return hasCoreChunk; + return hasSources; } private static void AppendBuildSources(ChunkNavBuildSourceDescriptor[] descriptors, List results) @@ -356,58 +570,87 @@ namespace InfiniteWorld.VoxelWorld.NavMesh private void CompleteFinishedBuild() { - if (!activeBuildRegion.HasValue) + if (!activeBuildWindowId.HasValue) { return; } - if (!navRegions.TryGetValue(activeBuildRegion.Value, out NavRegionRuntime region)) + if (!coverageWindows.TryGetValue(activeBuildWindowId.Value, out NavCoverageWindowRuntime window)) { - activeBuildRegion = null; + activeBuildWindowId = null; return; } - if (region.ActiveBuild != null && !region.ActiveBuild.isDone) + if (window.ActiveBuild != null && !window.ActiveBuild.isDone) { return; } - region.ActiveBuild = null; - Vector2Int completedRegion = activeBuildRegion.Value; - activeBuildRegion = null; + window.ActiveBuild = null; + window.State = NavCoverageState.Ready; + int completedWindowId = activeBuildWindowId.Value; + activeBuildWindowId = null; - if (region.BuildRequestedWhileRunning) + if (window.BuildRequestedWhileRunning) { - region.BuildRequestedWhileRunning = false; - EnqueueDirtyRegion(completedRegion); + window.BuildRequestedWhileRunning = false; + EnqueueDirtyCoverageWindow(completedWindowId); } } - private NavRegionRuntime GetOrCreateRegion(Vector2Int regionCoord) + private void RemoveCoverageWindow(int windowId) { - 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)) + if (!coverageWindows.TryGetValue(windowId, out NavCoverageWindowRuntime window)) { return; } - if (activeBuildRegion.HasValue && activeBuildRegion.Value == regionCoord) + if (activeBuildWindowId.HasValue && activeBuildWindowId.Value == windowId) { - activeBuildRegion = null; + activeBuildWindowId = null; } - region.Dispose(); - navRegions.Remove(regionCoord); + window.Dispose(); + coverageWindows.Remove(windowId); + queuedCoverageWindowIds.Remove(windowId); + } + + private static void RemoveCoverageData(NavCoverageWindowRuntime window) + { + if (window.ActiveBuild != null && !window.ActiveBuild.isDone && window.NavMeshData != null) + { + UnityNavMeshBuilder.Cancel(window.NavMeshData); + } + + if (window.Instance.valid) + { + UnityNavMesh.RemoveNavMeshData(window.Instance); + window.Instance = default; + } + + window.ActiveBuild = null; + window.NavMeshData = null; + } + + private Bounds GetChunkWorldBounds(Vector2Int chunkCoord) + { + float chunkSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); + Vector3 min = new Vector3(chunkCoord.x * chunkSize, -500f, chunkCoord.y * chunkSize); + Vector3 size = new Vector3(chunkSize, 1000f, chunkSize); + return new Bounds(min + new Vector3(chunkSize * 0.5f, 0f, chunkSize * 0.5f), size); + } + + private Bounds ExpandCoverageBounds(Bounds bounds, int chunkMargin) + { + return ExpandChunkBounds(bounds, chunkMargin); + } + + private Bounds ExpandChunkBounds(Bounds bounds, int chunkMargin) + { + float chunkSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); + float horizontalPadding = chunkMargin * chunkSize; + bounds.Expand(new Vector3(horizontalPadding * 2f, 0f, horizontalPadding * 2f)); + return bounds; } private static Bounds CalculateBounds(List sources) @@ -472,79 +715,145 @@ namespace InfiniteWorld.VoxelWorld.NavMesh bounds.size = size; } - private Vector2Int WorldToChunk(Vector3 position) + private static Bounds CreateQuantizedCoverageBounds(Bounds rawBounds, float padding, float minSize, float quantizationStep) { - float chunkSize = Mathf.Max(1f, chunkNavSourceReader.ChunkWorldSize); - return new Vector2Int( - Mathf.FloorToInt(position.x / chunkSize), - Mathf.FloorToInt(position.z / chunkSize)); + Vector3 min = rawBounds.min; + Vector3 max = rawBounds.max; + + min.x -= padding; + min.z -= padding; + max.x += padding; + max.z += padding; + + EnsureMinimumSpan(ref min.x, ref max.x, minSize); + EnsureMinimumSpan(ref min.z, ref max.z, minSize); + + min.x = quantizationStep * Mathf.Floor(min.x / quantizationStep); + min.z = quantizationStep * Mathf.Floor(min.z / quantizationStep); + max.x = quantizationStep * Mathf.Ceil(max.x / quantizationStep); + max.z = quantizationStep * Mathf.Ceil(max.z / quantizationStep); + + Vector3 center = new Vector3((min.x + max.x) * 0.5f, 0f, (min.z + max.z) * 0.5f); + Vector3 size = new Vector3(Mathf.Max(max.x - min.x, minSize), 0.1f, Mathf.Max(max.z - min.z, minSize)); + return new Bounds(center, size); } - private Vector2Int ChunkToRegion(Vector2Int chunkCoord) + private static void EnsureMinimumSpan(ref float min, ref float max, float minimumSize) { - 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) + float currentSize = max - min; + if (currentSize >= minimumSize) { - return false; + return; } - foreach (Vector2Int value in left) + float halfPadding = (minimumSize - currentSize) * 0.5f; + min -= halfPadding; + max += halfPadding; + } + + private static float DistanceToBoundsXZ(Bounds bounds, Vector3 point) + { + float dx = Mathf.Max(bounds.min.x - point.x, 0f, point.x - bounds.max.x); + float dz = Mathf.Max(bounds.min.z - point.z, 0f, point.z - bounds.max.z); + return Mathf.Sqrt(dx * dx + dz * dz); + } + + private static float DistanceBetweenBoundsXZ(Bounds left, Bounds right) + { + float dx = Mathf.Max(left.min.x - right.max.x, 0f, right.min.x - left.max.x); + float dz = Mathf.Max(left.min.z - right.max.z, 0f, right.min.z - left.max.z); + return Mathf.Sqrt(dx * dx + dz * dz); + } + + private static bool ContainsXZ(Bounds bounds, Vector3 position) + { + return position.x >= bounds.min.x && position.x <= bounds.max.x + && position.z >= bounds.min.z && position.z <= bounds.max.z; + } + + private static bool IntersectsXZ(Bounds left, Bounds right) + { + return left.min.x <= right.max.x && left.max.x >= right.min.x + && left.min.z <= right.max.z && left.max.z >= right.min.z; + } + + private static bool BoundsApproximatelyEqual(Bounds left, Bounds right) + { + return Vector3.SqrMagnitude(left.center - right.center) < 0.0001f + && Vector3.SqrMagnitude(left.size - right.size) < 0.0001f; + } + + private readonly struct DesiredCoverageWindow + { + public DesiredCoverageWindow(Bounds coverageBounds, float priority, int interestCount) { - if (!right.Contains(value)) - { - return false; - } + CoverageBounds = coverageBounds; + Priority = priority; + InterestCount = interestCount; } - return true; + public Bounds CoverageBounds { get; } + public float Priority { get; } + public int InterestCount { get; } } - private static int PositiveModulo(int value, int modulus) + private struct ClusterAccumulator { - int result = value % modulus; - return result < 0 ? result + modulus : result; + public ClusterAccumulator(WorldInterestPoint point) + { + RawBounds = new Bounds(new Vector3(point.Position.x, 0f, point.Position.z), new Vector3(0.1f, 0.1f, 0.1f)); + Priority = point.Priority; + InterestCount = 1; + } + + public Bounds RawBounds; + public float Priority; + public int InterestCount; + + public void Add(WorldInterestPoint point) + { + RawBounds.Encapsulate(new Vector3(point.Position.x, 0f, point.Position.z)); + Priority = Mathf.Max(Priority, point.Priority); + InterestCount++; + } + + public void Merge(ClusterAccumulator other) + { + RawBounds.Encapsulate(other.RawBounds.min); + RawBounds.Encapsulate(other.RawBounds.max); + Priority = Mathf.Max(Priority, other.Priority); + InterestCount += other.InterestCount; + } } - private sealed class NavRegionRuntime : IDisposable + private sealed class NavCoverageWindowRuntime : IDisposable { + public NavCoverageWindowRuntime(int id, Bounds coverageBounds, float priority, int interestCount) + { + Id = id; + CoverageBounds = coverageBounds; + Priority = priority; + InterestCount = interestCount; + State = NavCoverageState.Pending; + } + + public int Id { get; } + public Bounds CoverageBounds; + public Bounds CollectionBounds; + public Bounds BuildBounds; + public float Priority; + public int InterestCount; + public NavCoverageState State; public NavMeshData NavMeshData; public NavMeshDataInstance Instance; public AsyncOperation ActiveBuild; public bool BuildRequestedWhileRunning; - public Bounds BuildBounds; + public bool MatchedThisFrame; 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; + RemoveCoverageData(this); + State = NavCoverageState.Pending; } } } diff --git a/Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs b/Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs new file mode 100644 index 00000000..1b3b67e7 --- /dev/null +++ b/Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using InfiniteWorld.VoxelWorld; +using InfiniteWorld.VoxelWorld.Contracts; +using UnityEngine; + +namespace VoxelWorldScene +{ + public sealed class SceneWorldInterestReader : IWorldInterestReader + { + private readonly VoxelWorldGenerator worldGenerator; + private VoxelWorldSpawnAnchor[] spawnAnchors; + private int lastAnchorRefreshFrame = -1; + + public SceneWorldInterestReader(VoxelWorldGenerator worldGenerator) + { + this.worldGenerator = worldGenerator; + } + + public int InterestVersion => worldGenerator != null ? worldGenerator.InterestVersion : 0; + + public void GetInterestPoints(List results) + { + if (results == null) + { + return; + } + + worldGenerator?.GetInterestPoints(results); + RefreshSpawnAnchors(); + + if (spawnAnchors == null) + { + return; + } + + for (int i = 0; i < spawnAnchors.Length; i++) + { + VoxelWorldSpawnAnchor anchor = spawnAnchors[i]; + if (anchor == null || !anchor.isActiveAndEnabled) + { + continue; + } + + results.Add(new WorldInterestPoint(anchor.transform.position, anchor.Priority, WorldInterestKind.SpawnAnchor)); + } + } + + private void RefreshSpawnAnchors() + { + if (lastAnchorRefreshFrame == Time.frameCount) + { + return; + } + + lastAnchorRefreshFrame = Time.frameCount; + spawnAnchors = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + } + } +} diff --git a/Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs.meta b/Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs.meta new file mode 100644 index 00000000..dd2b7c38 --- /dev/null +++ b/Assets/Scripts/VoxelWorld/SceneWorldInterestReader.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f1f0155f1e6452486d2f44f9dcefd5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs b/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs index 5e2ae99b..b8ae77e2 100644 --- a/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs +++ b/Assets/Scripts/VoxelWorld/VoxelWorldNavMeshLifetimeScope.cs @@ -30,8 +30,9 @@ namespace VoxelWorldScene builder.RegisterMessagePipe(); builder.RegisterInstance(config); - builder.RegisterInstance(worldGenerator).As().As().AsSelf(); - builder.RegisterEntryPoint(); + builder.RegisterInstance(worldGenerator).As().AsSelf(); + builder.Register(Lifetime.Singleton).As(); + builder.RegisterEntryPoint().AsSelf(); builder.RegisterBuildCallback(ResolvePublishers); } @@ -48,4 +49,5 @@ namespace VoxelWorldScene resolver.Resolve>()); } } + } diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs b/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs index dad7aa55..15faee3e 100644 --- a/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs +++ b/Assets/Scripts/VoxelWorld/VoxelWorldPlayerStreamTargetBinding.cs @@ -40,11 +40,6 @@ namespace VoxelWorldScene return explicitStreamTarget; } - if (currentStreamTarget != null) - { - return currentStreamTarget; - } - CameraFollow[] cameraFollows = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); for (int i = 0; i < cameraFollows.Length; i++) { @@ -55,6 +50,16 @@ namespace VoxelWorldScene } } + VoxelWorldSpawnAnchor[] spawnAnchors = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + for (int i = 0; i < spawnAnchors.Length; i++) + { + VoxelWorldSpawnAnchor anchor = spawnAnchors[i]; + if (anchor != null && anchor.isActiveAndEnabled) + { + return anchor.transform; + } + } + return null; } diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs b/Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs new file mode 100644 index 00000000..733e85eb --- /dev/null +++ b/Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs @@ -0,0 +1,12 @@ +using UnityEngine; + +namespace VoxelWorldScene +{ + [DisallowMultipleComponent] + public sealed class VoxelWorldSpawnAnchor : MonoBehaviour + { + [SerializeField, Min(0.01f)] private float priority = 2f; + + public float Priority => priority; + } +} diff --git a/Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs.meta b/Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs.meta new file mode 100644 index 00000000..a281d436 --- /dev/null +++ b/Assets/Scripts/VoxelWorld/VoxelWorldSpawnAnchor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a0a7758ae4541b39ed0b5d1fe912869 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: