Entity Component System(ECS), 모든 상황에서 빠를까?
Entity Component System
앞으로 있을 프로젝트를 위해 Babylon.js를 독학하며 간단한 게임을 개발하고 있다. 그래서 게임 쪽 용어들을 접하는 경우가 많아졌다. 그 중에 눈에 들어온 용어가 Entity Component System 이었다.
무엇인고 하니, 기존에는 게임에서 캐릭터를 만든다면 캐릭터 클래스 안에 체력, 이동 등의 요소가 모여있었다면 ECS는 단순히 캐릭터(Entity)에 체력, 이동 등의 부품(Component)을 붙일 뿐이고, 시스템(System)은 자신이 담당한 컴포넌트 조합을 가진 엔티티만 확인해서 데이터를 갱신한다는 것이다.
아무래도 나는 게임 개발은 문외한이기 때문에, 아무것도 없는 레고 블럭(Entity)에 칼(Component), 날개(Component)를 붙여주고, 칼이 있는 애들만 싸움에 보내고(System), 날개가 있는 애들만 공중에 매달아주는 것(System)으로 이해했다.
왜 주목 받았을까?
원래 게임 엔진은 초당 몇십~몇백번의 루프를 돌며 각 객체들의 상태를 업데이트하는데, 이때, 각 루프마다 게임 내 생성된 객체를 모두 돌면서 업데이트를 하는 것으로 알고있다. 캐릭터의 이동을 체크할 때, 매 루프마다 모든 캐릭터를 순회하면서 이 캐릭터의 움직임을 이동 처리해야 하는지 검사하는 것이다.
하지만 움직임을 체크할 때 각 객체를 순회하면서 객체 내의 좌표 데이터를 확인하는 방식에서 문제가 있을 수 있다. 일반적으로 객체가 여럿 있다면 그 객체들은 메모리 상에 연속으로 할당되어 있을 확률이 낮다는 것이다. 그래서 데이터를 확인하는데 시간이 더 걸릴 수 있다는 내용의 글을 보았다.
하지만 ECS는 같은 컴포넌트끼리 배열에 저장하기 때문에, 데이터를 체크할 때 더 빠르고 효율적으로 체크할 수 있다는 것이다. 예를 들어 이동을 처리해야할 경우, Position과 Velocity 컴포넌트를 가진 엔티티만 시스템이 찾아서 처리하는 것이다.
이런 이유 때문에 ECS가 대량의 객체를 다루는 게임에선 기존의 패턴보다 좋다는 설명이 이어졌다.
호기심
그래서, "오 그렇구나!" 하면서 Babylon.js에 bitECS 라이브러리를 결합해 게임을 만들어보고 있었는데 문득 이런 생각이 들었다.
얼마나 차이가 나길래 ECS를 추천하는 걸까?
지금 내가 만드는 게임은 3D 배경에 2D 스프라이트 캐릭터들이 움직이며 싸우는, 매우 간단한 게임이다. 그리고 그 캐릭터들조차 많아봤자 한 맵에 5개 정도 밖에 없을 정도로 매우 적은 수이다.
이 정도 숫자에도 수치상 성능 향상을 볼 수 있을까?
그래서 게임 개발을 잠시 멈추고, Babylon.js와 ECS를 이용한 벤치마크 프로그램을 만들어보기로 했다(물론 Codex로).
벤치마크 프로그램 개발 시작
처음에는 Babylon.js에서 기존 방식과 ECS 방식을 비교하는 식으로 진행했다. 10,000개의 객체가 이동하면서 색상이 변경되도록 처리했다. 이 정도면 성능 차이가 충분히 날 것이라 생각했다.
측정 조건은 다음과 같이 설정했다.
측정 조건
- 객체 수:
10,000 - 색상 변경 주기:
80ms - Warm-up:
20s - Sample:
60s - 렌더링 방식:
- Babylon.js:
WebGPUEngine - Three.js:
WebGPURenderer
- Babylon.js:
- ECS 라이브러리:
bitecs@0.4.0 - 공통 동작:
- 각 객체는 X/Z축으로 이동
- Y축은 0 이상에서 점프하듯 이동
- 각 객체 색상은 일정 주기로 무작위 변경
- 동일 seed를 사용해 초기 조건을 최대한 동일하게 구성
측정 하드웨어/브라우저
- 브라우저:
크롬(버전 147.0.7727.138(공식 빌드) (arm64)) - 하드웨어:
Apple M4 Pro(12 core CPU/16 core GPU) | 48GB RAM
기존방식
ECS 방식
하지만 실제 결과는 예상과 달랐다.
// babylon.js 기존 방식
{
"mode": "babylon-normal",
"averageFps": 20.00126674695756,
"minFps": 20,
"averageFrameMs": 49.996833333174386,
"p95FrameMs": 50,
"averageUpdateMs": 1.586499991416931,
"p95UpdateMs": 1.700000286102295,
"averageRenderMs": 52.11100000222524,
"p95RenderMs": 55.700000286102295,
"meshCount": 10001,
"activeMeshCount": 6359,
"totalVertices": 240004,
"totalIndices": 228894
}
// babylon.js ECS 방식
{
"mode": "babylon-bitecs",
"averageFps": 20.05009192356369,
"minFps": 20,
"averageFrameMs": 49.87508305758733,
"p95FrameMs": 50,
"averageUpdateMs": 2.4842192730634314,
"p95UpdateMs": 2.6999998092651367,
"averageRenderMs": 50.95946843362726,
"p95RenderMs": 59.299999713897705,
"meshCount": 10001,
"activeMeshCount": 6358,
"totalVertices": 240004,
"totalIndices": 228858
}
각각의 지표는 다음을 뜻한다.
FPS: 초당 프레임 수(높을수록 좋음)
Frame: 프레임 하나를 처리하는 데 걸린 시간(낮을수록 좋음)
Update: 객체 상태를 갱신하는데 걸린 시간(낮을수록 좋음)
Render: 엔진이 현재 상태를 렌더링하는데 걸린 시간(낮을수록 좋음)
대부분의 값들이 거의 차이가 없었다. Render도 오차 범위 내로 차이가 없었고, Update는 반대로 ECS가 느렸다.
이를 물어보니 AI들이 공통적으로 내놓은 의견은 동일했는데, 패턴의 문제보단, Render의 문제라는 것이었다. 두 패턴 모두 Render에 50ms 이상이 소요되었는데, 여기서 병목이 발생했기 때문에 실제 패턴에 따른 성능 차이를 확인할 수 없다는 것.
혹시 Babylon.js 가 너무 무거워서 그런 것일까? 라는 생각에 Three.js를 이용해서도 테스트를 진행해봤다.
// Three.js 기존 방식
{
"mode": "three-normal",
"averageFps": 20.91792379553882,
"minFps": 20,
"averageFrameMs": 47.805891721111955,
"p95FrameMs": 49.30000019073486,
"averageUpdateMs": 3.209872612527981,
"p95UpdateMs": 3.3000001907348633,
"averageRenderMs": 44.10780255505993,
"p95RenderMs": 45.5,
"meshCount": 10001,
"activeMeshCount": 10001,
"totalVertices": 240004,
"totalIndices": 360006
}
// Three.js ECS 방식
{
"mode": "three-bitecs",
"averageFps": 21.257809245809696,
"minFps": 20,
"averageFrameMs": 47.041536050904135,
"p95FrameMs": 48.40000009536743,
"averageUpdateMs": 2.6985893369094707,
"p95UpdateMs": 2.8000001907348633,
"averageRenderMs": 43.838087782217045,
"p95RenderMs": 45.19999980926514,
"meshCount": 10001,
"activeMeshCount": 10001,
"totalVertices": 240004,
"totalIndices": 360006
}
하지만 Three.js 에서도 결과는 비슷했다. FPS 값이 조금 높아졌으나 결국 렌더링에서 병목이 발생하고 있었다.
이를 어떻게 처리해야할 지 고민하니, Codex에서는 InstancedMesh를 이용하여 엔티티를 표시하는 방법을 제안했다.
1개의 원본 메쉬만 만들고, 나머지 수십만 개의 위치/회전/색상 정보는 단순 배열에 담아서 렌더링을 하는 것이다. 이러면 적은 수의 드로우 콜로 수십만 개의 복제본을 그려내게 되는 것이다. 이를 통해 렌더링 병목을 줄일 수 있고 ECS 사용 여부에 따른 성능 차이를 확인할 수 있다는 것이었다.
곧바로 해당 방법을 추가하도록 지시했다. Extreme 메뉴를 추가하여 50,000개 이상의 엔티티에서 기존 방식과 ECS 방식을 비교할 수 있게 되었다. 이 상태에서 비교하니 드디어 차이가 나기 시작했다.
// babylon.js 기존 방식
{
"mode": "babylon-thin-normal-extreme",
"averageFps": 63.01890567170151,
"minFps": 40.48583027214815,
"averageFrameMs": 15.868253968253969,
"p95FrameMs": 16.199999809265137,
"averageUpdateMs": 15.281851851750934,
"p95UpdateMs": 15.5,
"averageRenderMs": 0.14026454996179652,
"p95RenderMs": 0.20000028610229492,
"meshCount": 2,
"activeMeshCount": 2,
"totalVertices": 28,
"totalIndices": 10800006
}
// babylon.js ECS 방식
{
"mode": "babylon-thin-bitecs-extreme",
"averageFps": 111.96020378330905,
"minFps": 20,
"averageFrameMs": 8.931745086275729,
"p95FrameMs": 9.199999809265137,
"averageUpdateMs": 8.157385351687687,
"p95UpdateMs": 8.400000095367432,
"averageRenderMs": 0.1310303729513417,
"p95RenderMs": 0.20000028610229492,
"meshCount": 2,
"activeMeshCount": 2,
"totalVertices": 28,
"totalIndices": 10800006
}
Instancing을 통해 렌더링 병목이 크게 줄어든 것으로 보였고, Update 값을 통해서 ECS가 기존 방식 대비 평균 15.28 ms vs 8.16 ms 로 꽤 빠르게 개체의 값을 업데이트하는 것을 확인할 수 있었다.
다만, 아무리 Instancing 을 통해 렌더링 병목이 해결되었다지만 평균 렌더링 시간이 0.13 - 0.14 ms 밖에 안 되는 것은 의아했다.
결론
그런데 생각해보니, 이 방법은 동일한 형태의 객체를 수십만 개 다룰 때 사용할 수 있는 방식이지 각각 다른 형태의 객체를 렌더링 할 때는 사용할 수 없는 방법이었다. 여기까지 테스트해보고 알게된 것은
- 단순한 로직과 적은 개체일 경우 기존 방식도 충분히 빠름
- 렌더링 병목 시 효과 미미
- 동일한 형태의 개체를 대량 렌더링할 때는 Instancing이 효과적
- 수십만 개의 개체 중 일부만 데이터가 갱신되는 상황에서는 ECS가 효과적
이 정도였다. 일단 내가 지금 개발 중인 게임은 엄청 많은 개체들이 나오는 것도 아니니 ECS 방식에서 기존 방식으로 변경하는 것도 고려해봐야할 것 같다. 그리고 이번 단순 대량 Mesh 벤치마크에서는 Three.js가 Babylon.js보다 좋은 수치를 보였는데, 단순 메쉬가 아니라 복잡한 그래픽 결과물을 표시할 때도 더 좋은 퍼포먼스가 나오는지 더 테스트 해봐야할 것 같다.
단순히 웹에 있는 내용들만 보고 기술을 선택했는데, 직접 테스트해보며 어떤 상황에서 어떤 기술을 사용해야할지 경험해봐서 다음에 기술 스택을 정할 때는 좀 더 나은 대안을 선택할 수 있게 되어서 다행이라 생각한다.
