단연코 지금까지 작성한 모든 포스팅중 가장 어려웠다고 말할 수 있다.
지금까지 본인은 최적화를 신경쓰지 않고 맘 편하게 맵에 존재하는 모든 몬스터 정보를 긁어온다던가 플레이어를 찾아 작업을 한다던가 등 비용이 큰 작업을 시원시원하게 하고있었다. 이를 계속하다보니 서버가 슬슬 메모리가 쌓여 버거워하는 모습을 볼 수 있었다.(노트북으로 해서 그런것도 있음...)
그래서 마음먹고 클라이언트와 서버를 최적화 해보겠다.
클라이언트는 오브젝트 풀링으로
서버는 Zone과 VisionCube라는 개념을 도입해서 해결한다.
클라이언트 최적화
사실 지금까지 작업이 이펙트가 많은것도 아니고 수 많은 객체들이 Spawn되거나 하는것이 아니여서 클라이언트는 무리가 가지 않는다. 하지만 추후 미래를 바라봐서 만약 플레이어가 1000명이 넘고 한 곳에 몰린다면 클라이언트도 분명 버거워할 것이다.
그래서 오브젝트 풀링을 도입해서 자주 사용하는 오브젝트는 플레이어가 접속 하자마자 미리 Spawn을 시키고 꺼내쓰는 식으로 해보자.
필자는 Managers.Resource.Instantiate() 함수를 제작해 오브젝트를 생성하고 있었다. 이 부분을 전체 수정하기는 그러니 PoolManager를 만들어 관여하게 해보자.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ResourceManager
{
public T Load<T>(string path) where T : Object
{
if (typeof(T) == typeof(GameObject))
{
string name = path;
int index = name.LastIndexOf('/');
if (index >= 0)
name = name.Substring(index + 1);
GameObject go = Managers.Pool.GetOriginal(name);
if (go != null)
return go as T;
}
return Resources.Load<T>(path);
}
public GameObject Instantiate(string path, Transform parent = null)
{
GameObject original = Load<GameObject>($"Prefabs/{path}");
if (original == null)
{
Debug.Log($"Failed to load prefab : {path}");
return null;
}
if (original.GetComponent<Poolable>() != null)
return Managers.Pool.Pop(original, parent).gameObject;
GameObject go = Object.Instantiate(original, parent);
go.name = original.name;
return go;
}
public void Destroy(GameObject go)
{
if (go == null)
return;
Poolable poolable = go.GetComponent<Poolable>();
if (poolable != null)
{
Managers.Pool.Push(poolable);
return;
}
Object.Destroy(go);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PoolManager
{
#region Pool
class Pool
{
public GameObject Original { get; private set; }
public Transform Root { get; set; }
Stack<Poolable> _poolStack = new Stack<Poolable>();
public void Init(GameObject original, int count = 5)
{
Original = original;
Root = new GameObject().transform;
Root.name = $"{original.name}_Root";
for (int i = 0; i < count; i++)
Push(Create());
}
Poolable Create()
{
GameObject go = Object.Instantiate<GameObject>(Original);
go.name = Original.name;
return go.GetOrAddComponent<Poolable>();
}
public void Push(Poolable poolable)
{
if (poolable == null)
return;
poolable.transform.parent = Root;
poolable.gameObject.SetActive(false);
poolable.IsUsing = false;
_poolStack.Push(poolable);
}
public Poolable Pop(Transform parent)
{
Poolable poolable;
if (_poolStack.Count > 0)
poolable = _poolStack.Pop();
else
poolable = Create();
poolable.gameObject.SetActive(true);
// DontDestroyOnLoad 해제 용도
if (parent == null)
poolable.transform.parent = Managers.Scene.CurrentScene.transform;
poolable.transform.parent = parent;
poolable.IsUsing = true;
return poolable;
}
}
#endregion
Dictionary<string, Pool> _pool = new Dictionary<string, Pool>();
Transform _root;
public void Init()
{
if (_root == null)
{
_root = new GameObject { name = "@Pool_Root" }.transform;
Object.DontDestroyOnLoad(_root);
}
}
public void CreatePool(GameObject original, int count = 5)
{
Pool pool = new Pool();
pool.Init(original, count);
pool.Root.parent = _root;
_pool.Add(original.name, pool);
}
public void Push(Poolable poolable)
{
string name = poolable.gameObject.name;
if (_pool.ContainsKey(name) == false)
{
GameObject.Destroy(poolable.gameObject);
return;
}
_pool[name].Push(poolable);
}
public Poolable Pop(GameObject original, Transform parent = null)
{
if (_pool.ContainsKey(original.name) == false)
CreatePool(original);
return _pool[original.name].Pop(parent);
}
public GameObject GetOriginal(string name)
{
if (_pool.ContainsKey(name) == false)
return null;
return _pool[name].Original;
}
public void Clear()
{
foreach (Transform child in _root)
GameObject.Destroy(child.gameObject);
_pool.Clear();
}
}
내가 지정한 오브젝트 (Poolable을 가진)를 소환할 때는 PoolManager에서 Pop을 해서 오브젝트를 가져올 수 있다.
만약 오브젝트가 없다면 5개를 만들어서 Push하고 그중 하나를 돌려준다.
따라서 만약 어떤 이펙트가 100개가 필요하다고 하면 Push와 Pop을 계속해서 마지막에는 결국 Pool안에 100개의 이펙트가 담겨있고 나중에 100개의 이펙트를 다시 요청했을 때는 생성할 필요없이 Pool 안에서 Pop을 통해 꺼내올 것이다.
이렇게 자주 사용되고 소환되어야하는 Effect들에 Poolable 스크립트만 추가하면 된다.
서버 최적화 아이디어
사실상 이번 포스팅에 주된 내용이라고 할 수 있다.
최적화에 가장 큰 도움이 된 Zone에 대해 먼저 알아보자.
우리가 이 전에 Broadcast를 어떻게 했는지 생각해보면
public void Broadcast(IMessage packet, int id = -1)
{
foreach(Player p in _players.Values)
{
if (id != -1 && p.Id == id) continue;
p.Session.Send(packet);
}
}
특정 게임룸안에 player Dictionary를 모두 순회하면서 게임에 접속한 모든 플레이어에게 정보를 전부 뿌렸다.
이게 문제가 되는 부분이 될 수 있다.
생각해보면 거리가 100만큼 떨어진 플레이어끼리는 서로 정보를 공유할 필요가 있을까? 라는 의문이든다.
어차피 서버에 모든 정보가 다 있기에 서로 시야에 보이지 않거나 영향을 끼치지 않는다면 굳이 브로드캐스트 때 서치를 할 필요가 없을 것이다.
만약 현재 동접이 1000명인 게임이라면 한 플레이어가 이동했을때 1000명에게 Broadcast를 해야하고 for문은 1000번을 돌것이다.
그리고 1000명이 동시에 이동한다면 1000 * 1000 만큼의 for문을 돌아야한다.
하지만 나와 특정 공간(여기서는 Zone)안에 있는 플레이어만 골라서 Broadcast를 한다면? 엄청난 최적화가 될것이다.
예를 들어 1000명이 현재 게임 안에 있지만 나와 같은 Zone 안에 있는 플레이어가 20명이라면 20명에게만 내 움직임을 전달하면 될것이다.
이것이 이번 주된 아이디어이다.
즉, 맵의 전체 사이즈를 지정한 후 그걸 내가 원하는 등분으로 나누어서 Zone으로 관리해보자.
가령 맵을 이런식의 배열로 관리할 수 있다.
다 좋긴 하다만 문제가 하나 발생한다. 만약 어떤 플레이어가 0,0 Zone과 0,1 Zone 사이에 애매하게 걸쳐있다고 해보자. 그러면 이 플레이어는 과연 0,0안에 있는 플레이어에게 Broadcast를 해야할까? 아니면 0,1 Zone에 Broadcast해야할까? 아님 둘다 해야할까??
바로 둘다 해야한다. 그렇다면 사이에 애매하게 걸쳐있다는 것의 기준이 필요하다.
그건 바로 VisionCube라는 개념을 이용한다.
우리가 게임을 하다보면 시야에서 먼 물체는 안보이다가 내가 다가가면 그제서야 렌더링 되거나 보이는경우가 있다.
그런 경우를 이용해서 플레이어를 기준으로 사각형 박스를 그려서 그 안에 어떤 오브젝트가 들어온다면 spawn하고 나가면 despawn하는 방법이다.
그리고 이를 활용해서 그 사각형의 꼭짓점 4개를 이용해서 그게 어느 Zone에 있는지 확인해서 꼭짓점이 존재하는 모든 Zone을 긁어와 그 Zone 안에서 Broadcast를 하면 될것이다.
Zone
using Google.Protobuf.Protocol;
using System;
using System.Collections.Generic;
using System.Text;
namespace Server.Game.Room
{
public class Zone
{
public int IndexY { get; private set; }
public int IndexX { get; private set; }
public HashSet<Player> Players { get; set; } = new HashSet<Player>();
public HashSet<Monster> Monsters { get; set; } = new HashSet<Monster>();
public HashSet<DropItem> DropItems { get; set; } = new HashSet<DropItem>();
public HashSet<Npc> Npcs { get; set; } = new HashSet<Npc>();
public Zone(int y, int x)
{
IndexY = y;
IndexX = x;
}
public void Remove(GameObject gameObject)
{
GameObjectType type = ObjectManager.GetObjectTypeById(gameObject.Id);
switch (type)
{
case GameObjectType.Player:
Players.Remove((Player)gameObject);
break;
case GameObjectType.Monster:
Monsters.Remove((Monster)gameObject);
break;
case GameObjectType.Dropitem:
DropItems.Remove((DropItem)gameObject);
break;
case GameObjectType.Npc:
Npcs.Remove((Npc)gameObject);
break;
}
}
public Player FindOnePlayer(Func<Player, bool> condition)
{
foreach (Player player in Players)
{
if (condition.Invoke(player))
return player;
}
return null;
}
public List<Player> FindAllPlayers(Func<Player, bool> condition)
{
List<Player> findList = new List<Player>();
foreach (Player player in Players)
{
if (condition.Invoke(player))
findList.Add(player);
}
return findList;
}
}
}
서버가 열린 후 밑의 코드에서 Init 할 때 Zone의 영역을 나누어준다. 그리고 좌표를 이용해서 현재 내가 어느 Zone에 있는지 확인한다.
public float mapMinX = 300;
public float mapMinZ = 300;
public float mapMaxX = 390;
public float mapMaxZ = 390;
public float mapSizeX { get { return mapMaxX - mapMinX; } }
public float mapSizeZ { get { return mapMaxZ - mapMinZ; } }
public Zone[,] Zones { get; private set; }
public int ZoneCells { get; private set; }
public Zone GetZone(Positions pos)
{
int x = (int)((pos.PosX - mapMinX) / ZoneCells);
int z = (int)((mapMaxZ - pos.PosZ ) / ZoneCells);
return GetZone(z, x);
}
public Zone GetZone(int z, int x)
{
if (x < 0 || x >= Zones.GetLength(1))
return null;
if (z < 0 || z >= Zones.GetLength(0))
return null;
return Zones[z, x];
}
public void Init(int mapId, int zoneCells)
{
ZoneCells = zoneCells;
int countZ = (int)((mapSizeZ + zoneCells - 1) / zoneCells);
int countX = (int)((mapSizeX + zoneCells - 1) / zoneCells);
Zones = new Zone[countZ, countX];
for (int z = 0; z < countZ; z++)
{
for (int x = 0; x < countX; x++)
{
Zones[z, x] = new Zone(z, x);
}
}
for (int i = 0; i < 1; i++)
{
SpawnMob();
}
Npc npc = ObjectManager.Instance.Add<Npc>();
npc.Init(1);
EnterGame(npc);
}
그리고 Broadcast는 다음과 같다. Master 플레이어가 존재하기 때문에(길 찾기) 따로 처리한 모습이다.
public void Broadcast(Positions pos, IMessage packet, int id = -1, bool includeMaster = true)
{
if (includeMaster)
BroadcastMaster(packet);
Broadcast(pos, packet, id);
}
public void Broadcast(Positions pos, IMessage packet, int id)
{
List<Zone> zones = GetAdjacentZones(pos);
foreach (Player p in zones.SelectMany(z => z.Players))
{
if ((id != -1 && p.Id == id) || p.Session.Master) continue;
int dx = (int)(p.Pos.PosX - pos.PosX);
int dz = (int)(p.Pos.PosZ - pos.PosZ);
if (Math.Abs(dx) > GameRoom.VisionCells)
continue;
if (Math.Abs(dz) > GameRoom.VisionCells)
continue;
p.Session.Send(packet);
}
}
public void BroadcastMaster(IMessage packet)
{
MasterPlayer?.Session.Send(packet);
}
public List<Player> GetAdjacentPlayers(Positions pos, int range = GameRoom.VisionCells)
{
List<Zone> zones = GetAdjacentZones(pos, range);
return zones.SelectMany(z => z.Players).ToList();
}
public List<Zone> GetAdjacentZones(Positions pos, int range = GameRoom.VisionCells)
{
HashSet<Zone> zones = new HashSet<Zone>();
int maxZ = (int)(pos.PosZ + range);
int minZ = (int)(pos.PosZ - range);
int maxX = (int)(pos.PosX + range);
int minX = (int)(pos.PosX - range);
int leftTopIndexY = (int)((mapMaxZ - maxZ) / ZoneCells);
int leftTopIndexX = (int)((minX - mapMinX) / ZoneCells);
int rightBotIndexY = (int)((mapMaxZ - minZ) / ZoneCells);
int rightBotIndexX = (int)((maxX - mapMinX) / ZoneCells);
int startIndexY = Math.Min(leftTopIndexY, rightBotIndexY);
int endIndexY = Math.Max(leftTopIndexY, rightBotIndexY);
int startIndexX = Math.Min(leftTopIndexX, rightBotIndexX);
int endIndexX = Math.Max(leftTopIndexX, rightBotIndexX);
// Iterate through the indices and collect zones
for (int y = startIndexY; y <= endIndexY; y++)
{
for (int x = startIndexX; x <= endIndexX; x++)
{
Zone zone = GetZone(y, x);
if (zone != null)
{
zones.Add(zone);
}
}
}
return zones.ToList();
}
플레이어가 위치가 변경되었을 때 Zone을 계속 바꾸어 준다.
Zone now = player.curZone;
Zone after = GetZone(playerPos);
if (after == null)
return;
if (now == after) return;
now.Players.Remove(player);
after.Players.Add(player);
player.curZone = after;
VisionCube
using Google.Protobuf.Protocol;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Server.Game.Room
{
public class VisionCube
{
public Player Owner { get; private set; }
public HashSet<GameObject> PreviousObjects { get; private set; } = new HashSet<GameObject>();
public VisionCube(Player owner)
{
Owner = owner;
}
public HashSet<GameObject> GatherObjects()
{
if (Owner == null || Owner.Room == null)
return null;
HashSet<GameObject> objects = new HashSet<GameObject>();
List<Zone> zones = Owner.Room.GetAdjacentZones(Owner.Pos);
Positions pos = Owner.Pos;
foreach (Zone zone in zones)
{
foreach (Player player in zone.Players)
{
float dx = pos.PosX - player.Pos.PosX;
float dz = pos.PosZ - player.Pos.PosZ;
if (Math.Abs(dx) > GameRoom.VisionCells)
continue;
if (Math.Abs(dz) > GameRoom.VisionCells)
continue;
objects.Add(player);
}
foreach (Monster monster in zone.Monsters)
{
float dx = pos.PosX - monster.Pos.PosX;
float dz = pos.PosZ - monster.Pos.PosZ;
if (Math.Abs(dx) > GameRoom.VisionCells)
continue;
if (Math.Abs(dz) > GameRoom.VisionCells)
continue;
objects.Add(monster);
}
foreach (DropItem dropItem in zone.DropItems)
{
float dx = pos.PosX - dropItem.Pos.PosX;
float dz = pos.PosZ - dropItem.Pos.PosZ;
if (Math.Abs(dx) > GameRoom.VisionCells)
continue;
if (Math.Abs(dz) > GameRoom.VisionCells)
continue;
objects.Add(dropItem);
}
foreach (Npc npc in zone.Npcs)
{
float dx = pos.PosX - npc.Pos.PosX;
float dz = pos.PosZ - npc.Pos.PosZ;
if (Math.Abs(dx) > GameRoom.VisionCells)
continue;
if (Math.Abs(dz) > GameRoom.VisionCells)
continue;
objects.Add(npc);
}
}
return objects;
}
public void Update()
{
if (Owner == null || Owner.Room == null)
return;
HashSet<GameObject> currentObjects = GatherObjects();
List<GameObject> added = currentObjects.Except(PreviousObjects).ToList();
if(added.Count > 0)
{
S_Spawn spawnPacket = new S_Spawn();
foreach (GameObject gameObject in added)
{
if(gameObject.ObjectType == GameObjectType.Player)
{
Player player = (Player)gameObject;
if (player.Session.Master == true) continue;
}
ObjectInfo info = new ObjectInfo();
info.MergeFrom(gameObject.Info);
spawnPacket.Objects.Add(info);
}
Owner.Session.Send(spawnPacket);
foreach(GameObject gameObject in added)
{
if(gameObject.ObjectType == GameObjectType.Player)
{
Player player = (Player)gameObject;
if (player.Session.Master == true) continue;
S_EquipItemList equipItemLists = new S_EquipItemList();
equipItemLists.ObjectId = player.Id;
for (int i = 0; i < player.Inven.EquipItems.Length; i++)
{
if (player.Inven.EquipItems[i] != null)
equipItemLists.TemplateIds.Add(player.Inven.EquipItems[i].TemplateId);
}
Owner.Session.Send(equipItemLists);
}
if (gameObject.isMoving)
{
S_Move resMovePacket = new S_Move();
resMovePacket.ObjectId = gameObject.Info.ObjectId;
resMovePacket.DestPosInfo = new PositionInfo();
resMovePacket.DestPosInfo.Pos = gameObject.DestPos;
if(gameObject.DestPos != null)
Owner.Session.Send(resMovePacket);
}
}
}
List<GameObject> removed = PreviousObjects.Except(currentObjects).ToList();
if(removed.Count > 0)
{
S_Despawn despawnPacket = new S_Despawn();
foreach (GameObject gameObject in removed)
{
despawnPacket.ObjectIds.Add(gameObject.Id);
}
Owner.Session.Send(despawnPacket);
}
PreviousObjects = currentObjects;
Owner.Room.PushAfter(100, Update);
}
}
}
0.1초마다 플레이어의 주변을 탐색해서 변경된 오브젝트가 있는지 HashSet을 이용해서 검사한 뒤 그것에 맞게 처리를 해주는 모습이다.
결과
영상에서 보면 알겠지만 시야가 멀어지면 VisionCube와 Zone으로 인해 오브젝트가 안보이게 된다. 이로써 클라이언트도 최적화가 될 것이고 서버 역시 연산 속도가 빨라질 것이다.
'Unity > 온라인 RPG' 카테고리의 다른 글
[Unity 3D] 보스 드래곤의 패턴 만들기 (0) | 2024.07.29 |
---|---|
[Unity 3D] 보스 원정대 꾸리기 + 보스 컷신 (3) | 2024.07.11 |
[Unity 3D] 상점 Npc - 아이템 구매, 판매하기 (0) | 2024.07.04 |
[Unity 3D] 아이템 인벤창에서 옮기기 (0) | 2024.06.21 |
[Unity 3D] UI 이미지 드래그 그리고 퀵슬롯 등록하기 (0) | 2024.06.21 |