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>
This commit is contained in:
mischa 2024-04-23 11:55:34 +08:00 committed by MrGadget
parent de870f3d67
commit 849a293b94
7 changed files with 346 additions and 0 deletions

View File

@ -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<T>
{
// the grid
// note that we never remove old keys.
// => over time, HashSet<T>s 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<Vector3Int, HashSet<T>> 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<Vector3Int, HashSet<T>>(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<T> 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<T>();
#else
hashSet = new HashSet<T>(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<T> result)
{
// return the set at position
if (grid.TryGetValue(position, out HashSet<T> 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<T> 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 HashSet<T>s 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<T> hashSet in grid.Values)
hashSet.Clear();
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b157c08313c64752b0856469b1b70771
timeCreated: 1713533175

View File

@ -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<NetworkConnectionToClient> grid = new Grid3D<NetworkConnectionToClient>(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<NetworkConnectionToClient> 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
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 120b4d6121d94e0280cd2ec536b0ea8f
timeCreated: 1713534045

View File

@ -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;

View File

@ -0,0 +1,83 @@
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
namespace Mirror.Tests.InterestManagement
{
public class Grid3DTests
{
Grid3D<int> grid;
[SetUp]
public void SetUp()
{
grid = new Grid3D<int>(10);
}
[Test]
public void AddAndGetNeighbours()
{
// add two at (0, 0, 0)
grid.Add(Vector3Int.zero, 1);
grid.Add(Vector3Int.zero, 2);
HashSet<int> result = new HashSet<int>();
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<int> result = new HashSet<int>();
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<int> result = new HashSet<int>();
grid.GetWithNeighbours(Vector3Int.zero, result);
Assert.That(result.Count, Is.EqualTo(0));
}
}
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 7aeeeb0f063e4f1c97fdde55fa0a4179
timeCreated: 1713533447