원래는

NativeParrallelMultiHashMap.AsParallelWriter()를 써보자!

였는데 글자수가 부족해서 잘렸다...


일단 왜 이런 타입을 쓰게 됐는지 살펴보자...

7cec8177abc236a14e81d2b628f17d6555b24f3c

이것은 내 게임이다.

마운트 & 블레이드에서 아쉬웠던 점 중 하나는 당시 우리 집의 컴퓨터의 빈약한 성능으로는 고작해야 200대200 싸움 밖에 구경 못했다는 것이다. 

그래서 우리 엄마의 8년 묵은 LG그램으로도 수천 규모의 전투가 돌아가는 마앤블 라이크 게임을 만들려고 하는데,

공격 판정<= 이건 DOTS의 무지막지한 최적화 능력으로도 다루기 어렵다. 


여기서 간단한 수학인데, 6000명의 유닛이 있다고 치자.

3000 vs 3000의 구도는 1vs 5999보다 훨씬 혼란스럽다.

컴퓨터 관점에서 생각하면, 3000vs3000에서의 연산 부하는 1vs 1800만이나 마찬가지다. 유닛 하나하나가 상대방 유닛의 위치가 자신의 공격 범위에 있는지 확인하고 공격할지 말지 결정한다고 치면, 6000개의 유닛은 상대편 3000개의 유닛을 "모두 고려하기" 때문이다.

진짜 그랬다간 cpu가 정상화 당하므로 최적화를 해야 한다.

-------------------------------------------------------------------------------------------------------


첫번째 고려할 만한 것은 모든 유닛 주위에 trigger 충돌 판정을 만드는 것이다. 

7fef8274b79c28a8699fe8b115ef0469207f704164

적 유닛이 이 구역에 들어오면 이 충돌 판정은 trigger 이벤트를 불러일으킨다.

굉장히 좋은 방법인데, 문제는 6000개 유닛의 trigger충돌 박스를 유지한다고 쳐도, 매 프레임마다 계속 울려대는 triggerevent가 천개는 넘는 다는 것이다.

공격 범위에 적이 있다 치면, 공격하는 시간과 멍 때리는 시간의 비율은 8:2~9:1 정도로 예상하고 있다. 그리고 공격을 하는 동안에는 이 trigger충돌 박스를 꺼버리고 싶어할 것이다.

근데 이 충돌 박스를 껐다 키고 하는 것이 유니티 dots의 물리 담당인 PhysicsWorld에 상당한 스트레스를 준다. 

그냥 켜두고 triggerEvent를 무시하면 되지 않음? 맞다. 하지만 그것은 우리가 이벤트 처리에서 걸러낸다는 것이지 충돌이 안 일어난다는 것은 아니다! 알람 소리를 무음으로 한다고 해서 알람이 안 울리는 것이 아니기 때문이다.



두번쨰로 고려할 만한 것은 spherecasting이다. 

7eee8375abc236a14e81d2b628f17668a04e32b2

raycasting이라는 거리와 방향을 확인하는 레이저빔을 사방으로 쏴서 특정 구역을 탐지하는 기술이다. 각 유닛이 적 탐지를 해야하는 타이밍에, 적재적소에서 이걸 쓰게 하는 것이다. 

문제는 spherecast는 unity dots의 물리 담당 api인 unity physics에서 지원해주지 않고 있다. 물론 이걸 직접 구현을 할 수 있겠지만 그러면 최적화를 장담할 수 없다.



세번째는 모종의 방식으로 후보군을 좁히고 그 중에서 단순 거리 비교를 하는 것이다. 

7cec8277b49c28a8699fe8b115ef0464db878f6e30

나는 그 중에 Spatial hashing이라는 방식을 사용했다.

맵을 체스판처럼 가르면, 유닛들은 저절로 위치에 따라 특정 칸 위에 서게 된다.

그럼 각 유닛은 공격 범위 내에 적이 있는지 확인하려고 할 때, 더이상 상대편 모든 유닛들의 위치가 아닌, 자신이 서 있는 칸과 주변 칸에 있는 적 유닛들의 위치만 알면 된다. 그러기 위해서는 모든 적의 위치를 이 지도에 등록하는 것이 필요하다. 바로 여기서 NativeParrallelMultiHashMap이 나서야 한다.


보자마자 뇌가 녹아버릴 거 같은 작명 센스다. 

기능적으로 보자면 Native | Parrallel | Multi | HashMap 4가지 요소라고 볼 수 있다.

Native는 이 타입이 NativeContainer라는, 유니티 DOTS의 UnManaged 타입의 자료 저장 방식이라는 것을 의미한다. 일반적인 C#의 리스트, 딕셔너리 같은 GC(가비지 컬렉션, 쓰레기 집합소가 아니라 안쓰는 메모리를 정리하는 것)가 관리(Managed)하는 데이터 타입을 유니티 DOTS환경에서는 사용할 수 없다. Native를 앞에 붙여서 이건 DOTS-friendly라는 것을 강조하는 것이다.

Parrallel는 문자 그대로 병렬 처리가 가능하다는 것이다. 이 데이터는 병렬 처리 중인 Job에 던져주면, 수많은 cpu코어가 달려들어서 서로 싸우지 않고 잘 나눠 먹는다.

Multi라는 것은 이 해시맵은 일반적인 해시맵처럼 Key-Value 쌍이 일부일처제가 아니라 일부다처제라는 것이다. 즉 하나의 key에 여러 값이 들어갈 수 있다.

HashMap은 키가 주어지면 그 키에 맞는 값을 매우 빠르게 반환해준다.

즉 NativeParrallelMultiHashMap은, 유니티 DOTS환경에서 쓸 수 있는, 병렬 처리에 특화된, 키-값 쌍이 1대 다가 될 수 있는 해시맵 자료구조다.


자 그러면 이제 유닛들의 위치 정보를 이 해시맵에 등록하면 된다.

위 그림처럼 지도를 체스판으로 만들었지만, 실제로는 체스판 같은 오브젝트를 만들지 않을 것이다. 

최적화라는 것은 자진으로 컴퓨터의 기쁨조가 되겠다는 것과 마찬가지기에 인간 다운 생각으로는 불가능하며, 현실 세계에서 탈인간급의 기행에서 그 영감을 얻을 수 있다. 현실에서 멀쩡하게 체스판에서 체스를 두는 사람이 있는가하면, 체스판 없이 서로 좌표만 얘기하면서 체스를 두는 미친 사람들이 있다. 하지만 컴퓨터 관점에서는 그게 올바른 코딩법이다. 

병렬 job에서 각 유닛의 localTransform을 얻어서, math.floor로 정수형으로 맞추고, 나온 값을 int2에 넣으면, x와 y는 곧 가로 세로 1유닛의 x열y행의 칸으로 취급할 수 있다.

이 칸을 Key값으로 저 HashMap에 넣으면, 체스판 오브젝트 생성 필요 없이 사람이 읽을 수 있는 key-값으로 이루어진 해시맵이 만들어진다.


NativeParrallelMultiHashMap.AsParallelWriter()를 Job에 넘겨주면, 멀티스레드에서 병렬 처리로 더욱 빨리 맵에 등록이 된다. 그러면 이제 다음 시스템으로 넘어가서, idle또는 move상태의 유닛들은 이 맵에서 자신 주위에 어떤 적 유닛들이 있는지 알 수 있다. 이것은 읽기만 하기에 병렬 처리에는 그다지 어렵지 않지만, nativeContainer의 특수한 api로 인해 NativeParallelMultiHashMapIterator<int2>라는 해시맵의 키 값을 순회하는 용도의 변수를 사용해야 한다.


7eee8472b19c28a8699fe8b115ef046c291247d93a

해시맵의 출력 메세지. 13행30열key에 엔티티 번호가 입력됐고, 그 뒤에 같은 key에 다른 엔티티들이 입력됐다.

미리 주의를 하자면 이런 수천개의 엔티티가 있는 씬에서는 Debug.Log를 쓸 때 먼저 엔티티들을 수십 사이즈로 줄이고 하는 것을 권장한다.