@@ -23,7 +23,7 @@ ECS에 대해 간단히 소개하자면 이렇다.
2323
2424조작하려는 데이터(IComponentData)를 Unity 게임 오브젝트에 부착(Authoring)할 수 있도록 하고(Baker) 동작을 수행(ISystem)하도록 지시한다.
2525
26- 설계할 환경은 10만 개의 총알과 적 개체이다.
26+ 설계할 환경은 10만 개의 총알과 2,000마리의 적 개체이다.
2727
2828##### 총알(Bullet)
2929``` csharp
@@ -209,6 +209,249 @@ Profiler를 통해 중간 점검을 해본다.
209209- Mesh를 Sphere에서 Cube로 바꾸고, 머티리얼의 ` Shadow Receive ` 를 끄고, ` GPU Instancing ` 을 활성화 했다.
210210![ 04] ( /images/Unity_ECS%20Test/04.png )
211211- 이제 한 프레임에 12.47ms 로 약 80fps의 출력을 확인할 수 있다.
212- - 삼각형의 숫자가 2.4M (240만 개)
212+ - 삼각형의 숫자가 2.4M (240만 개)로 줄어듦을 확인할 수 있다.
213213
214- ### 충돌 시스템
214+ ### 충돌 시스템
215+ ``` csharp
216+ public override void Update (float deltaTime )
217+ {
218+ foreach (var stageObject in GameStage .currentStage .stageObjects )
219+ {
220+ if ( stageObject is Enemy enemy )
221+ {
222+ float distanceToEnemy = Vector3 .length (pos - enemy .pos );
223+ if ( distanceToEnemy < radius )
224+ {
225+ // 적이 총알에 맞았을 때의 처리
226+ GameStage .currentStage .RemoveStageObject (this );
227+
228+ enemy .AddDamage (this .power );
229+ }
230+ }
231+ }
232+ }
233+ ```
234+ - 해당 의사코드를 그대로 구현한 ` WorstCollisionSystem ` 을 제작할 것이다.
235+ - 해당 코드는 모든 총알이 개별적으로 갖는다.
236+ - 시간복잡도 O(m * n) 을 갖는다.
237+
238+ ##### WorstCollisionSystem
239+ ``` cs
240+ partial struct WorstCollisionSystem : ISystem
241+ {
242+ [BurstCompile ]
243+ public void OnUpdate (ref SystemState state )
244+ {
245+ var ecbSingleton = SystemAPI .GetSingleton <EndSimulationEntityCommandBufferSystem .Singleton >();
246+ var ecb = ecbSingleton .CreateCommandBuffer (state .WorldUnmanaged );
247+
248+ foreach (var (bulletTransform , bullet , bulletEntity ) in SystemAPI .Query <RefRO <LocalTransform >, RefRO <Bullet >>().WithEntityAccess ())
249+ {
250+ foreach (var (enemyTransform , enemy , enemyEntity ) in SystemAPI .Query <RefRO <LocalTransform >, RefRO <Enemy >>().WithEntityAccess ())
251+ {
252+ float3 bulletPos = bulletTransform .ValueRO .Position ;
253+ float3 enemyPos = enemyTransform .ValueRO .Position ;
254+
255+ float distance = math .distance (bulletPos , enemyPos );
256+ float collisionRadius = bullet .ValueRO .Radius ;
257+ float bulletDamage = bullet .ValueRO .Power ;
258+
259+ if (distance <= collisionRadius )
260+ {
261+ ecb .DestroyEntity (enemyEntity );
262+ // todo: enemyEntity에게 bulletDamage 입히기
263+ break ;
264+ }
265+ }
266+ }
267+ }
268+ }
269+ ```
270+ - 세상의 총알과 적을 ` SystemAPI.Query<> ` 로 가져와 거리를 비교한다.
271+ - 총알의 거리에 들면 적 개체를 ` DestroyEntity() ` 한다.
272+ ![ Worst_Coll] ( /images/Unity_ECS%20Test/Worst_Coll.png )
273+ - Profiler로 확인했을 때, 282ms 중 263ms를 차지한다.(약 3.54fps)
274+
275+ ##### BestCollisionSystem
276+ 공간 분할 알고리즘을 적용하여 ` HashMap<격자 번호, 적 개체> ` 에 저장한다.<br >
277+ 총알의 입장에서 총알 주위 9칸(3 * 3) 격자에 적 개체가 있는지 확인하고 없다면 충돌 검사를 하지 않는다.
278+ ``` cs
279+ partial struct BestCollisionSystem : ISystem
280+ {
281+ private const float CELL_SIZE = 10 f ;
282+
283+ [BurstCompile ]
284+ public void OnUpdate (ref SystemState state )
285+ {
286+ var ecbSingleton = SystemAPI .GetSingleton <EndSimulationEntityCommandBufferSystem .Singleton >();
287+ var ecb = ecbSingleton .CreateCommandBuffer (state .WorldUnmanaged );
288+
289+ var enemyGrid = new NativeParallelMultiHashMap <int2 , EnemyGridData >(1000 , Allocator .Temp );
290+
291+ foreach (var (enemyTransform , enemy , enemyEntity ) in SystemAPI .Query <RefRO <LocalTransform >, RefRO <Enemy >>().WithEntityAccess ())
292+ {
293+ float3 position = enemyTransform .ValueRO .Position ;
294+ int2 cellCoord = new int2 ((int )math .floor (position .x / CELL_SIZE ), (int )math .floor (position .z / CELL_SIZE ));
295+
296+ enemyGrid .Add (cellCoord , new EnemyGridData
297+ {
298+ Entity = enemyEntity ,
299+ Position = position
300+ });
301+ }
302+
303+ foreach (var (bulletTransform , bullet , bulletEntity ) in SystemAPI .Query <RefRO <LocalTransform >, RefRO <Bullet >>().WithEntityAccess ())
304+ {
305+ float3 position = bulletTransform .ValueRO .Position ;
306+ float radius = bullet .ValueRO .Radius ;
307+ int2 cellCoord = new int2 ((int )math .floor (position .x / CELL_SIZE ), (int )math .floor (position .z / CELL_SIZE ));
308+
309+ bool hit = false ;
310+
311+ for (int i = - 1 ; i <= 1 ; i ++ )
312+ {
313+ for (int j = - 1 ; j <= 1 ; j ++ )
314+ {
315+ int2 checkCell = cellCoord + new int2 (i , j );
316+
317+ if (enemyGrid .TryGetFirstValue (checkCell , out var enemyData , out var iterator ))
318+ {
319+ do
320+ {
321+ float distance = math .distance (position , enemyData .Position );
322+
323+ if (distance <= radius )
324+ {
325+ ecb .DestroyEntity (enemyData .Entity );
326+ hit = true ;
327+ break ;
328+ }
329+ }
330+ while (enemyGrid .TryGetNextValue (out enemyData , ref iterator ));
331+ }
332+ if (hit ) break ;
333+ }
334+ if (hit ) break ;
335+ }
336+ }
337+
338+ enemyGrid .Dispose ();
339+ }
340+ }
341+
342+ struct EnemyGridData
343+ {
344+ public Entity Entity ;
345+ public float3 Position ;
346+ }
347+ ```
348+ - 첫 번째 ` foreach ` 에서 모든 적 개체의 좌표를 ` CELL_SIZE ` (격자 한 칸의 크기)로 나누어 ` HashMap ` 에 저장한다.
349+ - 두 번째 ` foreach ` 에서 총알 주변의 3 * 3 격자를 순회하며 총알과의 충돌 판정을 계산한다.
350+ - 이때, HashMap(` enemyGrid ` )에 적이 있든 없든 일단 조회를 한다.
351+ ![ Best_Coll] ( /images/Unity_ECS%20Test/Best_Coll.png )
352+ - Profiler로 확인했을 때, 13.58ms 중 5.59ms를 차지한다.(약 73.63 fps)
353+ ##### BitCollisionSystem
354+ ` BestCollisionSystem ` 과 똑같이 공간 분할을 하되 Bit 배열을 사용해서 적 개체가 있는 격자라면 비트를 켠다.<br >
355+ 해시 조회를 하기 전 격자에 적이 없다면(Bit 배열이 0인 경우) 해시 조회를 하지 않는다.
356+ ``` cs
357+ partial struct BitCollisionSystem : ISystem
358+ {
359+ private const float CELL_SIZE = 10 f ;
360+ private const int GRID_SIZE = 200 ;
361+ private const int GRID_OFFSET = 100 ;
362+ private const int TOTAL_CELLS = GRID_SIZE * GRID_SIZE ;
363+
364+ [BurstCompile ]
365+ public void OnUpdate (ref SystemState state )
366+ {
367+ var ecbSingleton = SystemAPI .GetSingleton <EndSimulationEntityCommandBufferSystem .Singleton >();
368+ var ecb = ecbSingleton .CreateCommandBuffer (state .WorldUnmanaged );
369+ var occupancyBits = new NativeBitArray (TOTAL_CELLS , Allocator .Temp , NativeArrayOptions .ClearMemory );
370+ var enemyGrid = new NativeParallelMultiHashMap <int2 , EnemyGridData >(1000 , Allocator .Temp );
371+
372+ foreach (var (enemyTransform , enemy , enemyEntity ) in SystemAPI .Query <RefRO <LocalTransform >, RefRO <Enemy >>().WithEntityAccess ())
373+ {
374+ float3 position = enemyTransform .ValueRO .Position ;
375+ int2 cellCoord = new int2 ((int )math .floor (position .x / CELL_SIZE ), (int )math .floor (position .z / CELL_SIZE ));
376+
377+ int x = cellCoord .x + GRID_OFFSET ;
378+ int z = cellCoord .y + GRID_OFFSET ;
379+ if (x >= 0 && x < GRID_SIZE && z >= 0 && z < GRID_SIZE )
380+ {
381+ enemyGrid .Add (cellCoord , new EnemyGridData
382+ {
383+ Entity = enemyEntity ,
384+ Position = position
385+ });
386+
387+ // GetLinearIndex 인라인
388+ int linearIndex = z * GRID_SIZE + x ;
389+ occupancyBits .Set (linearIndex , true );
390+ }
391+ }
392+
393+ foreach (var (bulletTransform , bullet , bulletEntity ) in SystemAPI .Query <RefRO <LocalTransform >, RefRO <Bullet >>().WithEntityAccess ())
394+ {
395+ float3 position = bulletTransform .ValueRO .Position ;
396+ float radius = bullet .ValueRO .Radius ;
397+ int2 cellCoord = new int2 ((int )math .floor (position .x / CELL_SIZE ), (int )math .floor (position .z / CELL_SIZE ));
398+ bool hit = false ;
399+
400+ for (int i = - 1 ; i <= 1 ; i ++ )
401+ {
402+ for (int j = - 1 ; j <= 1 ; j ++ )
403+ {
404+ int2 checkCell = cellCoord + new int2 (i , j );
405+ int checkX = checkCell .x + GRID_OFFSET ;
406+ int checkZ = checkCell .y + GRID_OFFSET ;
407+
408+ if (checkX < 0 || checkX >= GRID_SIZE || checkZ < 0 || checkZ >= GRID_SIZE )
409+ {
410+ continue ;
411+ }
412+
413+ int linearIndex = checkZ * GRID_SIZE + checkX ;
414+
415+ if (! occupancyBits .IsSet (linearIndex ))
416+ {
417+ continue ;
418+ }
419+
420+ if (enemyGrid .TryGetFirstValue (checkCell , out var enemyData , out var iterator ))
421+ {
422+ do
423+ {
424+ float distance = math .distance (position , enemyData .Position );
425+
426+ if (distance <= radius )
427+ {
428+ ecb .DestroyEntity (enemyData .Entity );
429+ hit = true ;
430+ break ;
431+ }
432+ }
433+ while (enemyGrid .TryGetNextValue (out enemyData , ref iterator ));
434+ }
435+ if (hit ) break ;
436+ }
437+ if (hit ) break ;
438+ }
439+ }
440+
441+ occupancyBits .Dispose ();
442+ enemyGrid .Dispose ();
443+ }
444+ }
445+ ```
446+ - 첫 번째 ` foreach ` 에서 적 위치를 격자에 넣는 것은 동일하지만 추가로 ` occupancyBits ` 에 값을 켜준다.
447+ - 두 번째 ` foreach ` 에서 3 * 3 격자를 순회할 때, ` occupancyBits.IsSet() ` 값이 0이면 해시 조회(` TryGetFirstValue() ` )를 하지 않는다.
448+ ![ Bit_Coll] ( /images/Unity_ECS%20Test/Bit_Coll.png )
449+ - Profiler로 확인했을 때, 11.03ms 중 2.76ms를 차지한다.(약 90.66 fps)
450+ ### 결과
451+ - 100,000개의 총알이 있는 세상에서 2,000마리의 적이 있을 때, 모든 총알과의 충돌 계산을 하는 것은 성능이 나쁘다.
452+ - 공간 분할을 통해 계산할 적의 숫자를 줄이는 것으로 3.54fps에서 73.63fps로의 성능 개선을 이룰 수 있다.
453+ - 추가로 격자에 적이 없다면 해시 조회를 하지 않아도 되므로 HasMap 조회보다 연산이 빠른 Bit 조회로 적의 유무를 미리 알고 계산을 건너 뛰는 방법을 생각할 수 있다.
454+ - 계산을 건너뛰게 된다면 73.64fps에서 90.66fps로의 성능 개선을 이룰 수 있다.
455+ - 하지만 세상에 100,000개 총알 중 적의 비율이 적기 때문에 적과 총알의 비율이 같아지는 순간이 오면 성능이 역전될 수 있다.
456+ - Unity Editor에서 적의 수를 20,000 정도로 늘리면 ` BestCollisionSystem ` 이 성능이 좋아지게 된다.
457+ - 하지만 세상의 크기를 반지름이 1,000인 원으로 제한했기 때문에 충돌이 일어나지 않는 경우보다 충돌이 일어나는 경우가 더 빈번하다는 점을 고려해야 한다.
0 commit comments