From 849a293b944bb16443241dee6baabe4c250cba8d Mon Sep 17 00:00:00 2001 From: mischa <16416509+miwarnec@users.noreply.github.com> Date: Tue, 23 Apr 2024 11:55:34 +0800 Subject: [PATCH] feature: SpatialHashing3D for XYZ to include vertical axis in checks (#3814) * Grid3D * comment * feature: SpatialHashing3D for XYZ to include vertical axis in checks --------- Co-authored-by: mischa <16416509+vis2k@users.noreply.github.com> --- .../SpatialHashing/Grid3D.cs | 106 +++++++++++++ .../SpatialHashing/Grid3D.cs.meta | 3 + .../SpatialHashing3DInterestManagement.cs | 146 ++++++++++++++++++ ...SpatialHashing3DInterestManagement.cs.meta | 3 + .../SpatialHashingInterestManagement.cs | 2 + .../Editor/InterestManagement/Grid3DTests.cs | 83 ++++++++++ .../InterestManagement/Grid3DTests.cs.meta | 3 + 7 files changed, 346 insertions(+) create mode 100644 Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs create mode 100644 Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs.meta create mode 100644 Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs create mode 100644 Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs.meta create mode 100644 Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs create mode 100644 Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs.meta diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs new file mode 100644 index 000000000..64ca4975d --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs @@ -0,0 +1,106 @@ +// Grid3D based on Grid2D +// -> not named 'Grid' because Unity already has a Grid type. causes warnings. +// -> struct to avoid memory indirection. it's accessed a lot. +using System.Collections.Generic; +using UnityEngine; + +namespace Mirror +{ + // struct to avoid memory indirection. it's accessed a lot. + public struct Grid3D + { + // the grid + // note that we never remove old keys. + // => over time, HashSets will be allocated for every possible + // grid position in the world + // => Clear() doesn't clear them so we don't constantly reallocate the + // entries when populating the grid in every Update() call + // => makes the code a lot easier too + // => this is FINE because in the worst case, every grid position in the + // game world is filled with a player anyway! + readonly Dictionary> grid; + + // cache a 9 x 3 neighbor grid of vector3 offsets so we can use them more easily + readonly Vector3Int[] neighbourOffsets; + + public Grid3D(int initialCapacity) + { + grid = new Dictionary>(initialCapacity); + + neighbourOffsets = new Vector3Int[9 * 3]; + int i = 0; + for (int x = -1; x <= 1; x++) + { + for (int y = -1; y <= 1; y++) + { + for (int z = -1; z <= 1; z++) + { + neighbourOffsets[i] = new Vector3Int(x, y, z); + i += 1; + } + } + } + } + + // helper function so we can add an entry without worrying + public void Add(Vector3Int position, T value) + { + // initialize set in grid if it's not in there yet + if (!grid.TryGetValue(position, out HashSet hashSet)) + { + // each grid entry may hold hundreds of entities. + // let's create the HashSet with a large initial capacity + // in order to avoid resizing & allocations. +#if !UNITY_2021_3_OR_NEWER + // Unity 2019 doesn't have "new HashSet(capacity)" yet + hashSet = new HashSet(); +#else + hashSet = new HashSet(128); +#endif + grid[position] = hashSet; + } + + // add to it + hashSet.Add(value); + } + + // helper function to get set at position without worrying + // -> result is passed as parameter to avoid allocations + // -> result is not cleared before. this allows us to pass the HashSet from + // GetWithNeighbours and avoid .UnionWith which is very expensive. + void GetAt(Vector3Int position, HashSet result) + { + // return the set at position + if (grid.TryGetValue(position, out HashSet hashSet)) + { + foreach (T entry in hashSet) + result.Add(entry); + } + } + + // helper function to get at position and it's 8 neighbors without worrying + // -> result is passed as parameter to avoid allocations + public void GetWithNeighbours(Vector3Int position, HashSet result) + { + // clear result first + result.Clear(); + + // add neighbours + foreach (Vector3Int offset in neighbourOffsets) + GetAt(position + offset, result); + } + + // clear: clears the whole grid + // IMPORTANT: we already allocated HashSets and don't want to do + // reallocate every single update when we rebuild the grid. + // => so simply remove each position's entries, but keep + // every position in there + // => see 'grid' comments above! + // => named ClearNonAlloc to make it more obvious! + public void ClearNonAlloc() + { + foreach (HashSet hashSet in grid.Values) + hashSet.Clear(); + } + } +} diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs.meta b/Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs.meta new file mode 100644 index 000000000..f4c202184 --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/Grid3D.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b157c08313c64752b0856469b1b70771 +timeCreated: 1713533175 \ No newline at end of file diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs new file mode 100644 index 000000000..1953de7e2 --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs @@ -0,0 +1,146 @@ +// extremely fast spatial hashing interest management based on uMMORPG GridChecker. +// => 30x faster in initial tests +// => scales way higher +// checks on three dimensions (XYZ) which includes the vertical axes. +// this is slower than XY checking for regular spatial hashing. +using System.Collections.Generic; +using UnityEngine; + +namespace Mirror +{ + [AddComponentMenu("Network/ Interest Management/ Spatial Hash/Spatial Hashing Interest Management")] + public class SpatialHashing3DInterestManagement : InterestManagement + { + [Tooltip("The maximum range that objects will be visible at.")] + public int visRange = 30; + + // 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. + public int resolution => visRange / 2; // same as XY because if XY is rotated 90 degree for 3D, it's still the same distance + + [Tooltip("Rebuild all every 'rebuildInterval' seconds.")] + public float rebuildInterval = 1; + double lastRebuildTime; + + [Header("Debug Settings")] + public bool showSlider; + + // the grid + // begin with a large capacity to avoid resizing & allocations. + Grid3D grid = new Grid3D(1024); + + // project 3d world position to grid position + Vector3Int ProjectToGrid(Vector3 position) => + Vector3Int.RoundToInt(position / resolution); + + public override bool OnCheckObserver(NetworkIdentity identity, NetworkConnectionToClient newObserver) + { + // calculate projected positions + Vector3Int projected = ProjectToGrid(identity.transform.position); + Vector3Int observerProjected = ProjectToGrid(newObserver.identity.transform.position); + + // distance needs to be at max one of the 8 neighbors, which is + // 1 for the direct neighbors + // 1.41 for the diagonal neighbors (= sqrt(2)) + // => use sqrMagnitude and '2' to avoid computations. same result. + return (projected - observerProjected).sqrMagnitude <= 2; // same as XY because if XY is rotated 90 degree for 3D, it's still the same distance + } + + public override void OnRebuildObservers(NetworkIdentity identity, HashSet newObservers) + { + // add everyone in 9 neighbour grid + // -> pass observers to GetWithNeighbours directly to avoid allocations + // and expensive .UnionWith computations. + Vector3Int current = ProjectToGrid(identity.transform.position); + grid.GetWithNeighbours(current, newObservers); + } + + [ServerCallback] + public override void ResetState() + { + lastRebuildTime = 0D; + } + + // update everyone's position in the grid + // (internal so we can update from tests) + [ServerCallback] + internal void Update() + { + // NOTE: unlike Scene/MatchInterestManagement, this rebuilds ALL + // entities every INTERVAL. consider the other approach later. + + // IMPORTANT: refresh grid every update! + // => newly spawned entities get observers assigned via + // OnCheckObservers. this can happen any time and we don't want + // them broadcast to old (moved or destroyed) connections. + // => players do move all the time. we want them to always be in the + // correct grid position. + // => note that the actual 'rebuildall' doesn't need to happen all + // the time. + // NOTE: consider refreshing grid only every 'interval' too. but not + // for now. stability & correctness matter. + + // clear old grid results before we update everyone's position. + // (this way we get rid of destroyed connections automatically) + // + // NOTE: keeps allocated HashSets internally. + // clearing & populating every frame works without allocations + grid.ClearNonAlloc(); + + // put every connection into the grid at it's main player's position + // NOTE: player sees in a radius around him. NOT around his pet too. + foreach (NetworkConnectionToClient connection in NetworkServer.connections.Values) + { + // authenticated and joined world with a player? + if (connection.isAuthenticated && connection.identity != null) + { + // calculate current grid position + Vector3Int position = ProjectToGrid(connection.identity.transform.position); + + // put into grid + grid.Add(position, connection); + } + } + + // 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; + } + } + +// OnGUI allocates even if it does nothing. avoid in release. +#if UNITY_EDITOR || DEVELOPMENT_BUILD + // slider from dotsnet. it's nice to play around with in the benchmark + // demo. + void OnGUI() + { + if (!showSlider) return; + + // only show while server is running. not on client, etc. + if (!NetworkServer.active) return; + + int height = 30; + int width = 250; + GUILayout.BeginArea(new Rect(Screen.width / 2 - width / 2, Screen.height - height, width, height)); + GUILayout.BeginHorizontal("Box"); + GUILayout.Label("Radius:"); + visRange = Mathf.RoundToInt(GUILayout.HorizontalSlider(visRange, 0, 200, GUILayout.Width(150))); + GUILayout.Label(visRange.ToString()); + GUILayout.EndHorizontal(); + GUILayout.EndArea(); + } +#endif + } +} diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs.meta b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs.meta new file mode 100644 index 000000000..b6a218b16 --- /dev/null +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashing3DInterestManagement.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 120b4d6121d94e0280cd2ec536b0ea8f +timeCreated: 1713534045 \ No newline at end of file diff --git a/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs index aba55a44d..0cb5e2313 100644 --- a/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs +++ b/Assets/Mirror/Components/InterestManagement/SpatialHashing/SpatialHashingInterestManagement.cs @@ -1,6 +1,8 @@ // extremely fast spatial hashing interest management based on uMMORPG GridChecker. // => 30x faster in initial tests // => scales way higher +// checks on two dimensions only(!), for example: XZ for 3D games or XY for 2D games. +// this is faster than XYZ checking but doesn't check vertical distance. using System.Collections.Generic; using UnityEngine; diff --git a/Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs b/Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs new file mode 100644 index 000000000..dc6f105b9 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; + +namespace Mirror.Tests.InterestManagement +{ + public class Grid3DTests + { + Grid3D grid; + + [SetUp] + public void SetUp() + { + grid = new Grid3D(10); + } + + [Test] + public void AddAndGetNeighbours() + { + // add two at (0, 0, 0) + grid.Add(Vector3Int.zero, 1); + grid.Add(Vector3Int.zero, 2); + HashSet result = new HashSet(); + grid.GetWithNeighbours(Vector3Int.zero, result); + Assert.That(result.Count, Is.EqualTo(2)); + Assert.That(result.Contains(1), Is.True); + Assert.That(result.Contains(2), Is.True); + + // add a neighbour at (1, 0, 1) + grid.Add(new Vector3Int(1, 0, 1), 3); + grid.GetWithNeighbours(Vector3Int.zero, result); + Assert.That(result.Count, Is.EqualTo(3)); + Assert.That(result.Contains(1), Is.True); + Assert.That(result.Contains(2), Is.True); + Assert.That(result.Contains(3), Is.True); + + // add a neighbour at (1, 1, 1) to test upper layer + grid.Add(new Vector3Int(1, 1, 1), 4); + grid.GetWithNeighbours(Vector3Int.zero, result); + Assert.That(result.Count, Is.EqualTo(4)); + Assert.That(result.Contains(1), Is.True); + Assert.That(result.Contains(2), Is.True); + Assert.That(result.Contains(3), Is.True); + Assert.That(result.Contains(4), Is.True); + + // add a neighbour at (1, -1, 1) to test upper layer + grid.Add(new Vector3Int(1, -1, 1), 5); + grid.GetWithNeighbours(Vector3Int.zero, result); + Assert.That(result.Count, Is.EqualTo(5)); + Assert.That(result.Contains(1), Is.True); + Assert.That(result.Contains(2), Is.True); + Assert.That(result.Contains(3), Is.True); + Assert.That(result.Contains(4), Is.True); + Assert.That(result.Contains(5), Is.True); + } + + [Test] + public void GetIgnoresTooFarNeighbours() + { + // add at (0, 0, 0) + grid.Add(Vector3Int.zero, 1); + + // get at (2, 0, 0) which is out of 9 neighbour radius + HashSet result = new HashSet(); + grid.GetWithNeighbours(new Vector3Int(2, 0, 0), result); + Assert.That(result.Count, Is.EqualTo(0)); + } + + [Test] + public void ClearNonAlloc() + { + // add some + grid.Add(Vector3Int.zero, 1); + grid.Add(Vector3Int.zero, 2); + + // clear and check if empty now + grid.ClearNonAlloc(); + HashSet result = new HashSet(); + grid.GetWithNeighbours(Vector3Int.zero, result); + Assert.That(result.Count, Is.EqualTo(0)); + } + } +} diff --git a/Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs.meta b/Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs.meta new file mode 100644 index 000000000..6865bf2a4 --- /dev/null +++ b/Assets/Mirror/Tests/Editor/InterestManagement/Grid3DTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7aeeeb0f063e4f1c97fdde55fa0a4179 +timeCreated: 1713533447 \ No newline at end of file