Skip to content

Commit e86a76a

Browse files
committed
Add Post
1 parent 724d507 commit e86a76a

5 files changed

Lines changed: 246 additions & 3 deletions

File tree

docs/posts/Unity_ECS Test.md

Lines changed: 246 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = 10f;
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 = 10f;
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인 원으로 제한했기 때문에 충돌이 일어나지 않는 경우보다 충돌이 일어나는 경우가 더 빈번하다는 점을 고려해야 한다.
319 KB
Loading
313 KB
Loading
335 KB
Loading
186 KB
Loading

0 commit comments

Comments
 (0)