From 7e1156a4d6a0ebf70a42fd95499f153709ffccfb Mon Sep 17 00:00:00 2001 From: Robin Rolf Date: Sat, 11 Feb 2023 16:04:04 +0000 Subject: [PATCH 01/12] wip faster spatial aoi using imbase --- .../FastSpatialInterestManagement.cs | 201 ++++++++++++++++++ .../FastSpatialInterestManagement.cs.meta | 3 + ...erestManagementTests_FastSpatialHashing.cs | 135 ++++++++++++ ...ManagementTests_FastSpatialHashing.cs.meta | 3 + 4 files changed, 342 insertions(+) create mode 100644 Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs create mode 100644 Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs.meta create mode 100644 Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs create mode 100644 Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs.meta diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs new file mode 100644 index 000000000..1bb039cc1 --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using Mirror; +using UnityEngine; + +public class FastSpatialInterestManagement : InterestManagementBase { + [Tooltip("The maximum range that objects will be visible at.")] + public int visRange = 30; + + private int TileSize => visRange / 3; + + // the grid + private Dictionary> grid = + new Dictionary>(); + + class Tracked { + public bool uninitialized; + public Vector2Int position; + public Transform transform; + public NetworkIdentity identity; + } + + private Dictionary tracked = new Dictionary(); + + public override void Rebuild(NetworkIdentity identity, bool initialize) { + // do nothing, we update every frame. + } + + public override bool OnCheckObserver(NetworkIdentity identity, NetworkConnectionToClient newObserver) { + // we build initial state during the normal loop too + return false; + } + + // update everyone's position in the grid + internal void LateUpdate() { + // only on server + if (!NetworkServer.active) return; + + RebuildAll(); + } + + // When a new entity is spawned + public override void OnSpawned(NetworkIdentity identity) { + // (limitation: we never expect identity.visibile to change) + if (identity.visible != Visibility.Default) { + return; + } + + // host visibility shim to make sure unseen entities are hidden + if (NetworkClient.active) { + SetHostVisibility(identity, false); + } + + if (identity.connectionToClient != null) { + // client always sees itself + AddObserver(identity.connectionToClient, identity); + } + + tracked.Add(identity, new Tracked { + uninitialized = true, + position = new Vector2Int(int.MaxValue, int.MaxValue), // invalid + transform = identity.transform, + identity = identity, + }); + } + + // when an entity is despawned/destroyed + public override void OnDestroyed(NetworkIdentity identity) { + // (limitation: we never expect identity.visibile to change) + if (identity.visible != Visibility.Default) { + return; + } + + var obj = tracked[identity]; + tracked.Remove(identity); + + if (!obj.uninitialized) { + // 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 entities and check if their positions changed + foreach (var trackedEntity in tracked.Values) { + Vector2Int pos = + Vector2Int.RoundToInt( + new Vector2(trackedEntity.transform.position.x, trackedEntity.transform.position.z) / TileSize); + if (pos != trackedEntity.position) { + // if the position changed, move entity about + Vector2Int oldPos = trackedEntity.position; + trackedEntity.position = pos; + // First: Remove from old grid position, but only if it was ever in the grid + if (!trackedEntity.uninitialized) { + RebuildRemove(trackedEntity.identity, oldPos, pos); + } + + RebuildAdd(trackedEntity.identity, oldPos, pos, trackedEntity.uninitialized); + trackedEntity.uninitialized = false; + } + } + } + + private void RebuildRemove(NetworkIdentity entity, Vector2Int oldPosition, Vector2Int newPosition) { + // sanity check + if (!grid[oldPosition].Remove(entity)) { + throw new InvalidOperationException("entity was not in the provided grid"); + } + + // for all tiles the entity could see at the old position + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + var tilePos = oldPosition + new Vector2Int(x, y); + // optimization: don't remove on overlapping tiles + if (Mathf.Abs(tilePos.x - newPosition.x) <= 1 && + Mathf.Abs(tilePos.y - newPosition.y) <= 1) { + continue; + } + + if (!grid.TryGetValue(tilePos, out HashSet tile)) { + continue; + } + + // update observers for all identites the entity could see and all players that could see it + foreach (NetworkIdentity identity in tile) { + // dont touch yourself (hah.) + if (identity == entity) { + continue; + } + + // if the identity is a player, remove the entity from it + if (identity.connectionToClient != null) { + RemoveObserver(identity.connectionToClient, entity); + } + + // if the entity is a player, remove the identity from it + if (entity.connectionToClient != null) { + RemoveObserver(entity.connectionToClient, identity); + } + } + } + } + } + + private void RebuildAdd(NetworkIdentity entity, Vector2Int oldPos, Vector2Int newPos, bool initialize) { + // for all tiles the entity now sees at the new position + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + var tilePos = newPos + new Vector2Int(x, y); + // optimization: don't add on overlapping tiles + if (!initialize && (Mathf.Abs(tilePos.x - oldPos.x) <= 1 && + Mathf.Abs(tilePos.y - oldPos.y) <= 1)) { + continue; + } + + if (!grid.TryGetValue(tilePos, out var tile)) { + continue; + } + + foreach (var identity in tile) { + // dont touch yourself (hah.) + if (identity == entity) { + continue; + } + + // if the identity is a player, add the entity to it + if (identity.connectionToClient != null) { + try { + AddObserver(identity.connectionToClient, entity); + } catch (ArgumentException e) { + // sanity check + Debug.LogError( + $"Failed to add {entity} (#{entity.netId}) to the observers of {identity} (#{identity.netId}) (case 1)\n{e}"); + } + } + + // if the entity is a player, add the identity to it + if (entity.connectionToClient != null) { + try { + AddObserver(entity.connectionToClient, identity); + } catch (ArgumentException e) { + // sanity check + Debug.LogError( + $"Failed to add {identity} (#{identity.netId}) to the observers of {entity} (#{entity.netId}) (case 2)\n{e}"); + } + } + } + } + } + + // add ourselves to the new grid position + if (!grid.TryGetValue(newPos, out HashSet addTile)) { + addTile = new HashSet(); + grid[newPos] = addTile; + } + + if (!addTile.Add(entity)) { + throw new InvalidOperationException("entity was already in the grid"); + } + } +} 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/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs b/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs new file mode 100644 index 000000000..d89dea5f1 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs @@ -0,0 +1,135 @@ +// default = no component = everyone sees everyone + +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Mirror.Tests +{ + public class InterestManagementTests_FastSpatialHashing : InterestManagementTests_Common + { + FastSpatialInterestManagement aoi; + + [SetUp] + public override void SetUp() + { + + // TODO: these are just copied from the base Setup methods since the aoi expects "normal" operation + // for example: OnSpawned to be called for spawning identities, late adding the aoi does not work currently + // the setup also adds each identity to spawned twice so that also causes some issues during teardown + instantiated = new List(); + + // need a holder GO. with name for easier debugging. + holder = new GameObject("MirrorTest.holder"); + + // need a transport to send & receive + Transport.active = transport = holder.AddComponent(); + + // A with connectionId = 0x0A, netId = 0xAA + CreateNetworked(out gameObjectA, out identityA); + connectionA = new NetworkConnectionToClient(0x0A); + connectionA.isAuthenticated = true; + connectionA.isReady = true; + connectionA.identity = identityA; + //NetworkServer.spawned[0xAA] = identityA; // TODO: this causes two the identities to end up in spawned twice + + // B + CreateNetworked(out gameObjectB, out identityB); + connectionB = new NetworkConnectionToClient(0x0B); + connectionB.isAuthenticated = true; + connectionB.isReady = true; + connectionB.identity = identityB; + //NetworkServer.spawned[0xBB] = identityB; // TODO: this causes two the identities to end up in spawned twice + + // need to start server so that interest management works + NetworkServer.Listen(10); + + // add both connections + NetworkServer.connections[connectionA.connectionId] = connectionA; + NetworkServer.connections[connectionB.connectionId] = connectionB; + + aoi = holder.AddComponent(); + aoi.visRange = 10; + // setup server aoi since InterestManagement Awake isn't called + NetworkServer.aoi = aoi; + + // spawn both so that .observers is created + NetworkServer.Spawn(gameObjectA, connectionA); + NetworkServer.Spawn(gameObjectB, connectionB); + } + + [TearDown] + public override void TearDown() + { + base.TearDown(); + // clear server aoi again + NetworkServer.aoi = null; + } + + public override void ForceHidden_Initial() + { + // doesnt support changing visibility at runtime + } + + public override void ForceShown_Initial() + { + // doesnt support changing visibility at runtime + } + + // brute force interest management + // => everyone should see everyone if in range + [Test] + public void InRange_Initial() + { + // A and B are at (0,0,0) so within range! + + aoi.LateUpdate(); + // both should see each other because they are in range + Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.True); + Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.True); + } + + // brute force interest management + // => everyone should see everyone if in range + [Test] + public void InRange_NotInitial() + { + // A and B are at (0,0,0) so within range! + + aoi.LateUpdate(); + // both should see each other because they are in range + Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.True); + Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.True); + } + + // brute force interest management + // => everyone should see everyone if in range + [Test] + public void OutOfRange_Initial() + { + // A and B are too far from each other + identityB.transform.position = Vector3.right * (aoi.visRange + 1); + + aoi.LateUpdate(); + // both should not see each other + Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.False); + Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.False); + } + + // brute force interest management + // => everyone should see everyone if in range + [Test] + public void OutOfRange_NotInitial() + { + // A and B are too far from each other + identityB.transform.position = Vector3.right * (aoi.visRange + 1); + + aoi.LateUpdate(); + // both should not see each other + Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.False); + Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.False); + } + + // TODO add tests to make sure old observers are removed etc. + } +} diff --git a/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs.meta b/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs.meta new file mode 100644 index 000000000..94d018b9e --- /dev/null +++ b/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: d74fcd64861c40f8856d3479137f49f2 +timeCreated: 1676129959 \ No newline at end of file From 338c0634a535e8b09b2292811200ec0bf5aa82cf Mon Sep 17 00:00:00 2001 From: Robin Rolf Date: Mon, 29 Jan 2024 18:18:10 +0100 Subject: [PATCH 02/12] move test to correct folder --- .../InterestManagementTests_FastSpatialHashing.cs | 2 +- .../InterestManagementTests_FastSpatialHashing.cs.meta | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename Assets/Mirror/Tests/Editor/{ => InterestManagement}/InterestManagementTests_FastSpatialHashing.cs (99%) rename Assets/Mirror/Tests/Editor/{ => InterestManagement}/InterestManagementTests_FastSpatialHashing.cs.meta (100%) diff --git a/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs similarity index 99% rename from Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs rename to Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs index d89dea5f1..34ec95cf0 100644 --- a/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs +++ b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs @@ -4,7 +4,7 @@ using NUnit.Framework; using UnityEngine; -namespace Mirror.Tests +namespace Mirror.Tests.InterestManagement { public class InterestManagementTests_FastSpatialHashing : InterestManagementTests_Common { diff --git a/Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs.meta b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs.meta similarity index 100% rename from Assets/Mirror/Tests/Editor/InterestManagementTests_FastSpatialHashing.cs.meta rename to Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs.meta From 5ede69c025b4eb6920192998f9f5161ed7804ca5 Mon Sep 17 00:00:00 2001 From: Robin Rolf Date: Tue, 30 Jan 2024 02:18:48 +0100 Subject: [PATCH 03/12] code cleanup, visibility handling, better tests --- .../FastSpatialInterestManagement.cs | 371 +++++++++++++----- Assets/Mirror/Core/InterestManagementBase.cs | 35 ++ ...erestManagementTests_FastSpatialHashing.cs | 207 ++++++---- 3 files changed, 428 insertions(+), 185 deletions(-) diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs index 1bb039cc1..8b0a44c08 100644 --- a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs @@ -1,38 +1,59 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; using Mirror; using UnityEngine; -public class FastSpatialInterestManagement : InterestManagementBase { +public class FastSpatialInterestManagement : InterestManagementBase +{ [Tooltip("The maximum range that objects will be visible at.")] public int visRange = 30; - private int TileSize => visRange / 3; + // 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 - private Dictionary> grid = + Dictionary> grid = new Dictionary>(); - class Tracked { - public bool uninitialized; - public Vector2Int position; - public Transform transform; - public NetworkIdentity identity; + class Tracked + { + public Vector2Int Position; + public Transform Transform; + public NetworkIdentity Identity; + public Visibility PreviousVisibility; + + public Vector2Int GridPosition(int tileSize) + { + Vector3 transformPos = Transform.position; + return Vector2Int.RoundToInt(new Vector2(transformPos.x, transformPos.z) / tileSize); + } } - private Dictionary tracked = new Dictionary(); + Dictionary trackedIdentities = new Dictionary(); - public override void Rebuild(NetworkIdentity identity, bool initialize) { - // do nothing, we update every frame. + 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) { + public override bool OnCheckObserver(NetworkIdentity identity, NetworkConnectionToClient newObserver) + { // we build initial state during the normal loop too return false; } - // update everyone's position in the grid - internal void LateUpdate() { + internal void LateUpdate() + { // only on server if (!NetworkServer.active) return; @@ -40,162 +61,312 @@ internal void LateUpdate() { } // When a new entity is spawned - public override void OnSpawned(NetworkIdentity identity) { - // (limitation: we never expect identity.visibile to change) - if (identity.visible != Visibility.Default) { - return; - } - - // host visibility shim to make sure unseen entities are hidden - if (NetworkClient.active) { + 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) { + if (identity.connectionToClient != null) + { // client always sees itself AddObserver(identity.connectionToClient, identity); } - tracked.Add(identity, new Tracked { - uninitialized = true, - position = new Vector2Int(int.MaxValue, int.MaxValue), // invalid - transform = identity.transform, - identity = identity, - }); + Tracked tracked = new Tracked + { + Transform = identity.transform, + Identity = identity, + PreviousVisibility = identity.visibility, + }; + + tracked.Position = tracked.GridPosition(TileSize); + trackedIdentities.Add(identity, tracked); + RebuildAdd( + identity, + InvalidPosition, + tracked.Position, + true + ); } + Vector2Int InvalidPosition => new Vector2Int(int.MaxValue, + int.MaxValue); + // when an entity is despawned/destroyed - public override void OnDestroyed(NetworkIdentity identity) { + public override void OnDestroyed(NetworkIdentity identity) + { // (limitation: we never expect identity.visibile to change) - if (identity.visible != Visibility.Default) { + if (identity.visibility != Visibility.Default) + { return; } - var obj = tracked[identity]; - tracked.Remove(identity); + Tracked obj = trackedIdentities[identity]; + trackedIdentities.Remove(identity); - if (!obj.uninitialized) { - // observers are cleaned up automatically when destroying, we just need to remove it from our grid - grid[obj.position].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 entities and check if their positions changed - foreach (var trackedEntity in tracked.Values) { - Vector2Int pos = - Vector2Int.RoundToInt( - new Vector2(trackedEntity.transform.position.x, trackedEntity.transform.position.z) / TileSize); - if (pos != trackedEntity.position) { - // if the position changed, move entity about - Vector2Int oldPos = trackedEntity.position; - trackedEntity.position = pos; - // First: Remove from old grid position, but only if it was ever in the grid - if (!trackedEntity.uninitialized) { - RebuildRemove(trackedEntity.identity, oldPos, pos); + private void RebuildAll() + { + // loop over all identities and check if their positions changed + foreach (Tracked tracked in trackedIdentities.Values) + { + // calculate the current grid position + Vector3 transformPos = tracked.Transform.position; + Vector2Int currentPosition = Vector2Int.RoundToInt(new Vector2(transformPos.x, transformPos.z) / TileSize); + bool visibilityChanged = tracked.Identity.visibility != tracked.PreviousVisibility; + // Visibility change to default is done before we run the normal grid update, since + if (visibilityChanged && tracked.Identity.visibility == Visibility.Default) + { + if (tracked.PreviousVisibility == Visibility.ForceHidden) + { + // Hidden To Default + AddObserversHiddenToDefault(tracked.Identity, tracked.Position); + } + else + { + // Shown To Default + RemoveObserversShownToDefault(tracked.Identity, tracked.Position); + } + } + // if the position changed, move entity about + if (currentPosition != tracked.Position) + { + Vector2Int oldPosition = tracked.Position; + tracked.Position = currentPosition; + // First: Remove from old grid position, but only if it was ever in the grid to begin with + RebuildRemove(tracked.Identity, oldPosition, currentPosition); + + // Then add to new grid tile + RebuildAdd( + tracked.Identity, + oldPosition, + currentPosition, + false + ); + } + + // after updating the grid, if the visibility has changed + if (visibilityChanged) + { + switch (tracked.Identity.visibility) + { + case Visibility.Default: + // handled above + break; + case Visibility.ForceHidden: + ClearObservers(tracked.Identity); + break; + case Visibility.ForceShown: + AddObserversAllReady(tracked.Identity); + break; + default: + throw new ArgumentOutOfRangeException(); } - RebuildAdd(trackedEntity.identity, oldPos, pos, trackedEntity.uninitialized); - trackedEntity.uninitialized = false; + tracked.PreviousVisibility = tracked.Identity.visibility; } } } - private void RebuildRemove(NetworkIdentity entity, Vector2Int oldPosition, Vector2Int newPosition) { + private void RebuildRemove(NetworkIdentity changedIdentity, Vector2Int oldPosition, Vector2Int newPosition) + { // sanity check - if (!grid[oldPosition].Remove(entity)) { + if (!grid[oldPosition].Remove(changedIdentity)) + { throw new InvalidOperationException("entity was not in the provided grid"); } // for all tiles the entity could see at the old position - for (int x = -1; x <= 1; x++) { - for (int y = -1; y <= 1; y++) { - var tilePos = oldPosition + new Vector2Int(x, y); + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + Vector2Int tilePos = oldPosition + new Vector2Int(x, y); // optimization: don't remove on overlapping tiles if (Mathf.Abs(tilePos.x - newPosition.x) <= 1 && - Mathf.Abs(tilePos.y - newPosition.y) <= 1) { + Mathf.Abs(tilePos.y - newPosition.y) <= 1) + { continue; } - if (!grid.TryGetValue(tilePos, out HashSet tile)) { + if (!grid.TryGetValue(tilePos, out HashSet tile)) + { continue; } // update observers for all identites the entity could see and all players that could see it - foreach (NetworkIdentity identity in tile) { - // dont touch yourself (hah.) - if (identity == entity) { + foreach (NetworkIdentity gridIdentity in tile) + { + if (gridIdentity == changedIdentity) + { + // Don't do anything with yourself continue; } - // if the identity is a player, remove the entity from it - if (identity.connectionToClient != null) { - RemoveObserver(identity.connectionToClient, entity); + // we only modify observers here if the visibility is default, ForceShown/ForceHidden are handled differently + + // 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 entity is a player, remove the identity from it - if (entity.connectionToClient != null) { - RemoveObserver(entity.connectionToClient, identity); + // 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 entity, Vector2Int oldPos, Vector2Int newPos, bool initialize) { + private void RebuildAdd(NetworkIdentity changedIdentity, Vector2Int oldPosition, Vector2Int newPosition, + bool initialize) + { // for all tiles the entity now sees at the new position - for (int x = -1; x <= 1; x++) { - for (int y = -1; y <= 1; y++) { - var tilePos = newPos + new Vector2Int(x, y); - // optimization: don't add on overlapping tiles - if (!initialize && (Mathf.Abs(tilePos.x - oldPos.x) <= 1 && - Mathf.Abs(tilePos.y - oldPos.y) <= 1)) { + 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 var tile)) { + if (!grid.TryGetValue(tilePos, out HashSet tile)) + { continue; } - foreach (var identity in tile) { - // dont touch yourself (hah.) - if (identity == entity) { + foreach (NetworkIdentity gridIdentity in tile) + { + if (gridIdentity == changedIdentity) + { + // Don't do anything with yourself continue; } - // if the identity is a player, add the entity to it - if (identity.connectionToClient != null) { - try { - AddObserver(identity.connectionToClient, entity); - } catch (ArgumentException e) { - // sanity check - Debug.LogError( - $"Failed to add {entity} (#{entity.netId}) to the observers of {identity} (#{identity.netId}) (case 1)\n{e}"); - } + // we only modify observers here if the visibility is default, ForceShown/ForceHidden are handled differently + // 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 entity is a player, add the identity to it - if (entity.connectionToClient != null) { - try { - AddObserver(entity.connectionToClient, identity); - } catch (ArgumentException e) { - // sanity check - Debug.LogError( - $"Failed to add {identity} (#{identity.netId}) to the observers of {entity} (#{entity.netId}) (case 2)\n{e}"); - } + // 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(newPos, out HashSet addTile)) { + if (!grid.TryGetValue(newPosition, out HashSet addTile)) + { addTile = new HashSet(); - grid[newPos] = addTile; + grid[newPosition] = addTile; } - if (!addTile.Add(entity)) { + if (!addTile.Add(changedIdentity)) + { throw new InvalidOperationException("entity 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 the entity now sees at the new position + 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 the entity now sees at the current position + // remove any connections that can still see 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 == changedIdentity) + { + // Don't do anything with yourself + continue; + } + + // if the gridIdentity is a player, it can see changedIdentity + 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/Core/InterestManagementBase.cs b/Assets/Mirror/Core/InterestManagementBase.cs index ec60d640e..5c6618aff 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 identity.observers.Values) + { + if (connection.isReady && !identity.observers.ContainsKey(connection.connectionId)) + { + connection.AddToObserving(identity); + identity.observers.Add(connection.connectionId, connection); + } + } + } + + /// 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 index 34ec95cf0..345ce40e9 100644 --- a/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs +++ b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs @@ -1,135 +1,172 @@ // default = no component = everyone sees everyone -using System.Collections.Generic; +using System; using NUnit.Framework; using UnityEngine; namespace Mirror.Tests.InterestManagement { - public class InterestManagementTests_FastSpatialHashing : InterestManagementTests_Common + 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.isReady = true; + connection.identity = identity; + NetworkServer.connections[connection.connectionId] = connection; + NetworkServer.Spawn(gameObject, connection); + return identity; + } + [SetUp] public override void SetUp() { - - // TODO: these are just copied from the base Setup methods since the aoi expects "normal" operation - // for example: OnSpawned to be called for spawning identities, late adding the aoi does not work currently - // the setup also adds each identity to spawned twice so that also causes some issues during teardown - instantiated = new List(); - - // need a holder GO. with name for easier debugging. - holder = new GameObject("MirrorTest.holder"); - - // need a transport to send & receive - Transport.active = transport = holder.AddComponent(); - - // A with connectionId = 0x0A, netId = 0xAA - CreateNetworked(out gameObjectA, out identityA); - connectionA = new NetworkConnectionToClient(0x0A); - connectionA.isAuthenticated = true; - connectionA.isReady = true; - connectionA.identity = identityA; - //NetworkServer.spawned[0xAA] = identityA; // TODO: this causes two the identities to end up in spawned twice - - // B - CreateNetworked(out gameObjectB, out identityB); - connectionB = new NetworkConnectionToClient(0x0B); - connectionB.isAuthenticated = true; - connectionB.isReady = true; - connectionB.identity = identityB; - //NetworkServer.spawned[0xBB] = identityB; // TODO: this causes two the identities to end up in spawned twice - + base.SetUp(); // need to start server so that interest management works NetworkServer.Listen(10); - // add both connections - NetworkServer.connections[connectionA.connectionId] = connectionA; - NetworkServer.connections[connectionB.connectionId] = connectionB; - aoi = holder.AddComponent(); aoi.visRange = 10; // setup server aoi since InterestManagement Awake isn't called NetworkServer.aoi = aoi; - - // spawn both so that .observers is created - NetworkServer.Spawn(gameObjectA, connectionA); - NetworkServer.Spawn(gameObjectB, connectionB); } [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; } - public override void ForceHidden_Initial() + private void AssertSelfVisible(NetworkIdentity id) { - // doesnt support changing visibility at runtime + // identities ALWAYS see themselves, if they have a player + if (id.connectionToClient != null) + { + Assert.That(id.observers.ContainsKey(id.connectionToClient.connectionId), Is.True); + } } - public override void ForceShown_Initial() - { - // doesnt support changing visibility at runtime - } - - // brute force interest management - // => everyone should see everyone if in range [Test] - public void InRange_Initial() + 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(b.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(); - // both should see each other because they are in range - Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.True); - Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.True); + AssertSelfVisible(a); + AssertSelfVisible(b); + Assert.That(a.observers.ContainsKey(b.connectionToClient.connectionId), Is.True); + Assert.That(b.observers.ContainsKey(b.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); } - // brute force interest management - // => everyone should see everyone if in range [Test] - public void InRange_NotInitial() + public void ForceShown() + { + } + + [Test] + public void InRangeInitial_To_OutRange() { // A and B are at (0,0,0) so within range! - - aoi.LateUpdate(); + var a = CreatePlayerNI(1); + var b = CreatePlayerNI(2); + AssertSelfVisible(a); + AssertSelfVisible(b); // both should see each other because they are in range - Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.True); - Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.True); - } - - // brute force interest management - // => everyone should see everyone if in range - [Test] - public void OutOfRange_Initial() - { - // A and B are too far from each other - identityB.transform.position = Vector3.right * (aoi.visRange + 1); - + 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(); - // both should not see each other - Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.False); - Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.False); - } - - // brute force interest management - // => everyone should see everyone if in range - [Test] - public void OutOfRange_NotInitial() - { - // A and B are too far from each other - identityB.transform.position = Vector3.right * (aoi.visRange + 1); - + 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(); - // both should not see each other - Assert.That(identityA.observers.ContainsKey(connectionB.connectionId), Is.False); - Assert.That(identityB.observers.ContainsKey(connectionA.connectionId), Is.False); + 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); } - // TODO add tests to make sure old observers are removed etc. + [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); + } } } From ebdd36f6fcb8bf9f31338565826650a35cb07ca7 Mon Sep 17 00:00:00 2001 From: Robin Rolf Date: Tue, 30 Jan 2024 02:19:54 +0100 Subject: [PATCH 04/12] comment --- Assets/Mirror/Core/InterestManagementBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Mirror/Core/InterestManagementBase.cs b/Assets/Mirror/Core/InterestManagementBase.cs index 5c6618aff..b8e1311a4 100644 --- a/Assets/Mirror/Core/InterestManagementBase.cs +++ b/Assets/Mirror/Core/InterestManagementBase.cs @@ -88,7 +88,7 @@ protected void AddObserversAllReady(NetworkIdentity identity) } } - /// Removes all observers from this identity + /// For ForceHidden: Removes all observers from this identity protected void ClearObservers(NetworkIdentity identity) { foreach (NetworkConnectionToClient connection in identity.observers.Values) From 12ce58871119cf0d824a22bfce8b26f2f6106683 Mon Sep 17 00:00:00 2001 From: Robin Rolf Date: Tue, 30 Jan 2024 02:20:55 +0100 Subject: [PATCH 05/12] comment --- .../SpatialHashing/FastSpatialInterestManagement.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs index 8b0a44c08..0ae18290b 100644 --- a/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/FastSpatialInterestManagement.cs @@ -48,7 +48,7 @@ public override void Rebuild(NetworkIdentity identity, bool initialize) public override bool OnCheckObserver(NetworkIdentity identity, NetworkConnectionToClient newObserver) { - // we build initial state during the normal loop too + // do nothing, we rebuild globally and individually in OnSpawned return false; } From 477d430a3fe8004ed252e653864ce4370f98dcb0 Mon Sep 17 00:00:00 2001 From: Robin Rolf Date: Tue, 30 Jan 2024 02:48:02 +0100 Subject: [PATCH 06/12] Test fixes & ForceShown test --- Assets/Mirror/Core/InterestManagementBase.cs | 2 +- ...erestManagementTests_FastSpatialHashing.cs | 51 +++++++++++++++++-- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/Assets/Mirror/Core/InterestManagementBase.cs b/Assets/Mirror/Core/InterestManagementBase.cs index b8e1311a4..681448bd2 100644 --- a/Assets/Mirror/Core/InterestManagementBase.cs +++ b/Assets/Mirror/Core/InterestManagementBase.cs @@ -78,7 +78,7 @@ protected void RemoveObserver(NetworkConnectionToClient connection, NetworkIdent /// For ForceShown: Makes sure all ready connections (that aren't already) are added to observers protected void AddObserversAllReady(NetworkIdentity identity) { - foreach (NetworkConnectionToClient connection in identity.observers.Values) + foreach (NetworkConnectionToClient connection in NetworkServer.connections.Values) { if (connection.isReady && !identity.observers.ContainsKey(connection.connectionId)) { diff --git a/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs index 345ce40e9..79879c7f7 100644 --- a/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs +++ b/Assets/Mirror/Tests/Editor/InterestManagement/InterestManagementTests_FastSpatialHashing.cs @@ -24,10 +24,14 @@ protected NetworkIdentity CreatePlayerNI(int connectionId, Action