Skip to content

Commit

Permalink
wip faster spatial aoi using imbase
Browse files Browse the repository at this point in the history
  • Loading branch information
imerr committed Jan 29, 2024
1 parent dd7337c commit 7e1156a
Show file tree
Hide file tree
Showing 4 changed files with 342 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<Vector2Int, HashSet<NetworkIdentity>> grid =
new Dictionary<Vector2Int, HashSet<NetworkIdentity>>();

class Tracked {
public bool uninitialized;
public Vector2Int position;
public Transform transform;
public NetworkIdentity identity;
}

private Dictionary<NetworkIdentity, Tracked> tracked = new Dictionary<NetworkIdentity, Tracked>();

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<NetworkIdentity> 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<NetworkIdentity> addTile)) {
addTile = new HashSet<NetworkIdentity>();
grid[newPos] = addTile;
}

if (!addTile.Add(entity)) {
throw new InvalidOperationException("entity was already in the grid");
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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<GameObject>();

// 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<MemoryTransport>();

// 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<FastSpatialInterestManagement>();
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.
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 7e1156a

Please sign in to comment.