diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs new file mode 100644 index 000000000..fd160ac95 --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using Mirror; +using UnityEngine; + +public class FastSpatialInterestManagement : InterestManagementBase +{ + Vector2Int InvalidPosition => new Vector2Int(int.MaxValue, + int.MaxValue); + + + [Tooltip("The maximum range that objects will be visible at.")] + public int visRange = 30; + + [Tooltip("Rebuild all every 'rebuildInterval' seconds.")] + public float rebuildInterval = 1; + + double lastRebuildTime; + + // we use a 9 neighbour grid. + // so we always see in a distance of 2 grids. + // for example, our own grid and then one on top / below / left / right. + // + // this means that grid resolution needs to be distance / 2. + // so for example, for distance = 30 we see 2 cells = 15 * 2 distance. + // + // on first sight, it seems we need distance / 3 (we see left/us/right). + // but that's not the case. + // resolution would be 10, and we only see 1 cell far, so 10+10=20. + int TileSize => visRange / 2; + + // the grid + Dictionary> grid = + new Dictionary>(); + + class Tracked + { + public Vector2Int Position; + public Transform Transform; + public NetworkIdentity Identity; + public Visibility PreviousVisibility; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Vector2Int GridPosition(int tileSize) + { + Vector3 transformPos = Transform.position; + return Vector2Int.RoundToInt(new Vector2(transformPos.x, transformPos.z) / tileSize); + } + } + + Dictionary trackedIdentities = new Dictionary(); + + public override void Rebuild(NetworkIdentity identity, bool initialize) + { + // do nothing, we rebuild globally and individually in OnSpawned + } + + public override bool OnCheckObserver(NetworkIdentity identity, NetworkConnectionToClient newObserver) + { + // do nothing, we rebuild globally and individually in OnSpawned + return false; + } + + internal void LateUpdate() + { + // only on server + if (!NetworkServer.active) return; + + // rebuild all spawned entities' observers every 'interval' + // this will call OnRebuildObservers which then returns the + // observers at grid[position] for each entity. + if (NetworkTime.localTime >= lastRebuildTime + rebuildInterval) + { + RebuildAll(); + lastRebuildTime = NetworkTime.localTime; + } + } + + // When a new identity is spawned + public override void OnSpawned(NetworkIdentity identity) + { + // host visibility shim to make sure unseen entities are hidden, we initialize actual visibility later + if (NetworkClient.active) + { + SetHostVisibility(identity, false); + } + + if (identity.connectionToClient != null) + { + // client always sees itself + AddObserver(identity.connectionToClient, identity); + } + + Tracked tracked = new Tracked + { + Transform = identity.transform, Identity = identity, PreviousVisibility = identity.visibility, + }; + + // set initial position + tracked.Position = tracked.GridPosition(TileSize); + // add to tracked + trackedIdentities.Add(identity, tracked); + // initialize in grid + RebuildAdd(identity, InvalidPosition, tracked.Position, true); + } + + + // when an identity is despawned/destroyed + public override void OnDestroyed(NetworkIdentity identity) + { + Tracked obj = trackedIdentities[identity]; + trackedIdentities.Remove(identity); + + // observers are cleaned up automatically when destroying, we just need to remove it from our grid + grid[obj.Position].Remove(identity); + } + + private void RebuildAll() + { + // loop over all identities and check if their position has changed + foreach (Tracked tracked in trackedIdentities.Values) + { + // Check if visibility has changed, this should usually be false + bool visibilityChanged = tracked.Identity.visibility != tracked.PreviousVisibility; + // Visibility change *to* default needs to be handled before the normal grid update + // since observers are manipulated in RebuildAdd/RebuildRemove if visibility == Default + if (visibilityChanged && tracked.Identity.visibility == Visibility.Default) + { + switch (tracked.PreviousVisibility) + { + case Visibility.ForceHidden: + // Hidden To Default + AddObserversHiddenToDefault(tracked.Identity, tracked.Position); + break; + case Visibility.ForceShown: + // Shown To Default + RemoveObserversShownToDefault(tracked.Identity, tracked.Position); + break; + case Visibility.Default: + default: + throw new ArgumentOutOfRangeException(); + } + } + + Vector2Int currentPosition = tracked.GridPosition(TileSize); + // if the position changed, move the identity in the grid and update observers accordingly + if (currentPosition != tracked.Position) + { + Vector2Int oldPosition = tracked.Position; + tracked.Position = currentPosition; + // First remove from old grid position + RebuildRemove(tracked.Identity, oldPosition, currentPosition); + // Then add to new grid position + RebuildAdd(tracked.Identity, oldPosition, currentPosition, false); + } + + // after updating the grid, if the visibility has changed + if (visibilityChanged) + { + switch (tracked.Identity.visibility) + { + case Visibility.ForceHidden: + ClearObservers(tracked.Identity); + break; + case Visibility.ForceShown: + AddObserversAllReady(tracked.Identity); + break; + case Visibility.Default: + break; + default: + throw new ArgumentOutOfRangeException(); + } + + tracked.PreviousVisibility = tracked.Identity.visibility; + } + } + } + + private void RebuildRemove(NetworkIdentity changedIdentity, Vector2Int oldPosition, Vector2Int newPosition) + { + // sanity check + if (!grid[oldPosition].Remove(changedIdentity)) + { + throw new InvalidOperationException("changedIdentity was not in the provided grid"); + } + + // for all tiles the changedIdentity could see at the old position + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + Vector2Int tilePos = oldPosition + new Vector2Int(x, y); + // Skip grid tiles that are still visible + if (Mathf.Abs(tilePos.x - newPosition.x) <= 1 && + Mathf.Abs(tilePos.y - newPosition.y) <= 1) + { + continue; + } + + if (!grid.TryGetValue(tilePos, out HashSet tile)) + { + continue; + } + + foreach (NetworkIdentity gridIdentity in tile) + { + if (gridIdentity == changedIdentity) + { + // Don't do anything with yourself + continue; + } + + // we only modify observers here if the visibility is default, ForceShown/ForceHidden are handled in RebuildAll + + // if the gridIdentity is a player, it can't see changedIdentity anymore + if (gridIdentity.connectionToClient != null && changedIdentity.visibility == Visibility.Default) + { + RemoveObserver(gridIdentity.connectionToClient, changedIdentity); + } + + // if the changedIdentity is a player, it can't see gridIdentity anymore + if (changedIdentity.connectionToClient != null && gridIdentity.visibility == Visibility.Default) + { + RemoveObserver(changedIdentity.connectionToClient, gridIdentity); + } + } + } + } + } + + private void RebuildAdd(NetworkIdentity changedIdentity, Vector2Int oldPosition, Vector2Int newPosition, + bool initialize) + { + // for all tiles the changedIdentity now sees at the new position + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + Vector2Int tilePos = newPosition + new Vector2Int(x, y); + + // Skip grid tiles that were already visible before moving + if (!initialize && (Mathf.Abs(tilePos.x - oldPosition.x) <= 1 && + Mathf.Abs(tilePos.y - oldPosition.y) <= 1)) + { + continue; + } + + if (!grid.TryGetValue(tilePos, out HashSet tile)) + { + continue; + } + + foreach (NetworkIdentity gridIdentity in tile) + { + if (gridIdentity == changedIdentity) + { + // Don't do anything with yourself + continue; + } + + // we only modify observers here if the visibility is default, ForceShown/ForceHidden are handled in RebuildAll + + // if the gridIdentity is a player, it can now see changedIdentity + if (gridIdentity.connectionToClient != null && changedIdentity.visibility == Visibility.Default) + { + AddObserver(gridIdentity.connectionToClient, changedIdentity); + } + + // if the changedIdentity is a player, it can now see gridIdentity + if (changedIdentity.connectionToClient != null && gridIdentity.visibility == Visibility.Default) + { + AddObserver(changedIdentity.connectionToClient, gridIdentity); + } + } + } + } + + // add ourselves to the new grid position + if (!grid.TryGetValue(newPosition, out HashSet addTile)) + { + addTile = new HashSet(); + grid[newPosition] = addTile; + } + + if (!addTile.Add(changedIdentity)) + { + throw new InvalidOperationException("identity was already in the grid"); + } + } + + /// Adds observers to the NI, but not the other way around. This is used when a NI changes from ForceHidden to Default + private void AddObserversHiddenToDefault(NetworkIdentity changed, Vector2Int gridPosition) + { + // for all tiles around the changedIdentity + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + Vector2Int tilePos = gridPosition + new Vector2Int(x, y); + if (!grid.TryGetValue(tilePos, out HashSet tile)) + { + continue; + } + + foreach (NetworkIdentity gridIdentity in tile) + { + if (gridIdentity == changed) + { + // Don't do anything with yourself + continue; + } + + // if the gridIdentity is a player, it can now see changedIdentity + if (gridIdentity.connectionToClient != null) + { + AddObserver(gridIdentity.connectionToClient, changed); + } + } + } + } + } + + // Temp hashset to avoid runtime allocation + private HashSet tempShownToDefaultSet = new HashSet(); + + /// Removes observers from the NI, but doesn't change observing. This is used when a NI changes from ForceShown to Default + private void RemoveObserversShownToDefault(NetworkIdentity changedIdentity, Vector2Int gridPosition) + { + tempShownToDefaultSet.Clear(); + // copy over all current connections that are seeing the NI + foreach (NetworkConnectionToClient observer in changedIdentity.observers.Values) + { + tempShownToDefaultSet.Add(observer); + } + + // for all tiles around the changedIdentity, remove any connections that can still see it + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + Vector2Int tilePos = gridPosition + new Vector2Int(x, y); + if (!grid.TryGetValue(tilePos, out HashSet tile)) + { + continue; + } + + foreach (NetworkIdentity gridIdentity in tile) + { + // if the gridIdentity is a player, it can see changedIdentity + // (also yourself! don't need the extra check here) + if (gridIdentity.connectionToClient != null) + { + tempShownToDefaultSet.Remove(gridIdentity.connectionToClient); + } + } + } + } + + // any left over connections can't see changedIdentity - thus need removing + foreach (NetworkConnectionToClient connection in tempShownToDefaultSet) + { + RemoveObserver(connection, changedIdentity); + } + + // clear when done + tempShownToDefaultSet.Clear(); + } +} diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs.meta b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs.meta new file mode 100644 index 000000000..c6715b4aa --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 86996600b40a4c41bb300ac0aed33189 +timeCreated: 1676129828 \ No newline at end of file diff --git a/Assets/Mirror/Core/InterestManagementBase.cs b/Assets/Mirror/Core/InterestManagementBase.cs index ec60d640e..681448bd2 100644 --- a/Assets/Mirror/Core/InterestManagementBase.cs +++ b/Assets/Mirror/Core/InterestManagementBase.cs @@ -1,6 +1,7 @@ // interest management component for custom solutions like // distance based, spatial hashing, raycast based, etc. // low level base class allows for low level spatial hashing etc., which is 3-5x faster. + using UnityEngine; namespace Mirror @@ -73,5 +74,39 @@ protected void RemoveObserver(NetworkConnectionToClient connection, NetworkIdent connection.RemoveFromObserving(identity, false); identity.observers.Remove(connection.connectionId); } + + /// For ForceShown: Makes sure all ready connections (that aren't already) are added to observers + protected void AddObserversAllReady(NetworkIdentity identity) + { + foreach (NetworkConnectionToClient connection in NetworkServer.connections.Values) + { + if (connection.isReady && !identity.observers.ContainsKey(connection.connectionId)) + { + connection.AddToObserving(identity); + identity.observers.Add(connection.connectionId, connection); + } + } + } + + /// For ForceHidden: Removes all observers from this identity + protected void ClearObservers(NetworkIdentity identity) + { + foreach (NetworkConnectionToClient connection in identity.observers.Values) + { + // Don't remove the client from observing its owned objects + if (connection != identity.connectionToClient) + { + connection.RemoveFromObserving(identity, false); + } + } + + // Clear.. + identity.observers.Clear(); + // If the object is owned by a client, add it's connection back to the observing set + if (identity.connectionToClient != null) + { + identity.observers.Add(identity.connectionToClient.connectionId, identity.connectionToClient); + } + } } } diff --git a/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs new file mode 100644 index 000000000..d399e615a --- /dev/null +++ b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs @@ -0,0 +1,223 @@ +// TODO: test despawning +// TODO: test non-player ni's +// TODO: test grid size changing at runtime +using System; +using NUnit.Framework; +using UnityEngine; + +namespace Mirror.Tests.InterestManagement +{ + public class InterestManagementTests_FastSpatialHashing : MirrorEditModeTest + { + FastSpatialInterestManagement aoi; + + protected NetworkIdentity CreateNI(Action prespawn = null) + { + CreateNetworked(out var gameObject, out var identity); + prespawn?.Invoke(identity); + NetworkServer.Spawn(gameObject); + return identity; + } + + protected NetworkIdentity CreatePlayerNI(int connectionId, Action prespawn = null) + { + CreateNetworked(out var gameObject, out var identity); + prespawn?.Invoke(identity); + NetworkConnectionToClient connection = new NetworkConnectionToClient(connectionId); + connection.isAuthenticated = true; + connection.identity = identity; + NetworkServer.connections.Add(connectionId, connection); + + NetworkServer.Spawn(gameObject, connection); + NetworkServer.SetClientReady(connection); // AddPlayerForConnection also calls this! + return identity; + } + + [SetUp] + public override void SetUp() + { + base.SetUp(); + // need to start server so that interest management works + NetworkServer.Listen(10); + + aoi = holder.AddComponent(); + aoi.visRange = 10; + aoi.rebuildInterval = -1; // rebuild every call :) + // setup server aoi since InterestManagement Awake isn't called + NetworkServer.aoi = aoi; + } + + [TearDown] + public override void TearDown() + { + foreach (GameObject go in instantiated) + { + if (go.TryGetComponent(out NetworkIdentity ni)) + { + // set isServer is false. otherwise Destroy instead of + // DestroyImmediate is called internally, giving an error in Editor + ni.isServer = false; + } + } + + // clear connections first. calling OnDisconnect wouldn't work since + // we have no real clients. + NetworkServer.connections.Clear(); + + base.TearDown(); + // clear server aoi again + NetworkServer.aoi = null; + } + + private void AssertSelfVisible(NetworkIdentity id) + { + // identities ALWAYS see themselves, if they have a player + if (id.connectionToClient != null) + { + Assert.That(id.observers.ContainsKey(id.connectionToClient.connectionId), Is.True); + } + } + + [Test] + public void ForceHidden() + { + // A and B are at (0,0,0) so within range! + var a = CreatePlayerNI(1, ni => ni.visibility = Visibility.ForceHidden); + var b = CreatePlayerNI(2); + + // no rebuild required here due to initial state :) + + AssertSelfVisible(a); + AssertSelfVisible(b); + // A should not be seen by B because A is force hidden + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.False); + // B should be seen by A + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + + // If we now set a to default, and rebuild, they should both see each other! + a.visibility = Visibility.Default; + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + // If we now set both hidden, and rebuild, they both won't see each other! + a.visibility = Visibility.ForceHidden; + b.visibility = Visibility.ForceHidden; + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.False); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.False); + } + + [Test] + public void ForceShown() + { + // A and B are at (0,0,0) so within range! + var a = CreatePlayerNI(1, ni => ni.visibility = Visibility.ForceShown); + var b = CreatePlayerNI(2); + + // no rebuild required here due to initial state :) + AssertSelfVisible(a); + AssertSelfVisible(b); + // A&B should see each other + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + aoi.LateUpdate(); + // rebuild doesnt change that + // no rebuild required here due to initial state :) + AssertSelfVisible(a); + AssertSelfVisible(b); + // A&B should see each other + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + + // If we now move A out of range of B + a.transform.position = new Vector3(aoi.visRange * 100, 0, 0); + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + + // a will be seen by B still + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + // But B is out of range of A + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.False); + + + // B to ForceShown: + b.visibility = Visibility.ForceShown; + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + // A&B should see each other + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + // A&B to default + a.visibility = Visibility.Default; + b.visibility = Visibility.Default; + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + // and they can't see each other anymore + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.False); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.False); + } + + [Test] + public void InRangeInitial_To_OutRange() + { + // A and B are at (0,0,0) so within range! + var a = CreatePlayerNI(1); + var b = CreatePlayerNI(2); + AssertSelfVisible(a); + AssertSelfVisible(b); + // both should see each other because they are in range + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + // update won't change that + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + // move out of range + a.transform.position = new Vector3(aoi.visRange * 100, 0, 0); + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + // and they'll see not each other anymore + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.False); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.False); + } + + [Test] + public void OutRangeInitial_To_InRange() + { + // A and B are not in range + var a = CreatePlayerNI(1, + ni => ni.transform.position = new Vector3(aoi.visRange * 100, 0, 0)); + var b = CreatePlayerNI(2); + + AssertSelfVisible(a); + AssertSelfVisible(b); + // both should not see each other because they aren't in range + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.False); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.False); + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + // update won't change that + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.False); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.False); + // move into range + a.transform.position = Vector3.zero; + aoi.LateUpdate(); + AssertSelfVisible(a); + AssertSelfVisible(b); + // and they'll see each other + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(a.connectionToClient.connectionId), Is.True); + } + } +} diff --git a/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs.meta b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs.meta new file mode 100644 index 000000000..94d018b9e --- /dev/null +++ b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d74fcd64861c40f8856d3479137f49f2 +timeCreated: 1676129959 \ No newline at end of file