왜 Flame인가
Flutter만으로 게임이 잘 안 되는 이유
Flutter는 위젯 트리 기반의 선언형 UI 프레임워크입니다. `setState` → `build` → diff → 레이아웃 → 페인트로 이어지는 파이프라인은 폼·리스트·대시보드 같은 애플리케이션 UI에는 탁월하지만, 매 프레임 수백 개의 객체 상태가 바뀌는 게임 루프에는 과잉 비용이 따릅니다. 16.6ms(60fps) 안에 위젯 트리를 재구성하고 RenderObject를 갱신하는 일은 오브젝트 수가 늘어날수록 빠르게 한계에 부딪힙니다.
Flame은 이 문제를 정공법으로 풉니다. 위젯 트리 대신 Component 트리를 별도로 운용하고, Flutter의 `CustomPainter` 위에 게임 월드를 직접 그립니다. 결과적으로 위젯 리빌드 비용이 사라지고, 캔버스 API만 호출하는 얇은 레이어가 됩니다.
저희 팀에서 withu_apps라는 Flutter 모노레포 아래 mind_test 심리테스트 앱과 여러 유틸 앱을 운영하면서, 미니게임 한두 개를 같은 저장소에 얹어볼 필요가 있었습니다. 처음엔 그냥 Flutter 위젯으로 뱀 게임 비슷한 것을 구현해 봤는데, 중간 밀도의 장면에서 jank가 먼저 눈에 들어왔습니다. Flame으로 갈아엎고 나서야 저사양 안드로이드에서도 프레임이 평탄해졌고, 그 경험이 이 글의 출발점이기도 합니다.
Flutter의 강점은 그대로
그럼에도 Flame은 Flutter 생태계를 버리지 않습니다. HUD(게임 내 UI)는 여전히 Flutter 위젯으로 올릴 수 있고, 상태 관리 솔루션(Riverpod, Bloc)과도 자연스럽게 연동됩니다. 하이브리드 구조는 인디 개발자와 소규모 팀에게 큰 생산성 이점이 됩니다.
---
Component-System 아키텍처
FCS(Flame Component System)의 기본
Flame의 모든 게임 요소는 `Component`를 상속합니다. `SpriteComponent`, `TextComponent`, `PositionComponent`는 모두 같은 생명주기(`onLoad`, `onMount`, `update`, `render`)를 공유합니다.
```dart class Player extends SpriteComponent with HasGameRef<MyGame>, CollisionCallbacks { Player() : super(size: Vector2(64, 64), anchor: Anchor.center);
@override Future<void> onLoad() async { sprite = await gameRef.loadSprite('player.png'); add(RectangleHitbox()); }
@override void update(double dt) { position += velocity * dt; super.update(dt); } } ```
핵심은 `dt`(delta time) 기반 업데이트입니다. 프레임 속도가 요동쳐도 게임 속도는 일정해야 하므로, 이동·회전·감쇠 같은 물리량은 반드시 `dt`를 곱해서 계산합니다.
HasComponentRef와 의존성 주입
서브 컴포넌트에서 루트 게임 인스턴스에 접근할 때는 `HasGameRef<T>` 믹스인을 사용합니다. 이 패턴은 컴포넌트를 느슨하게 결합시켜, 테스트 시 `MockGame`을 주입하는 것도 쉽습니다.
---
충돌 판정에서 놓치기 쉬운 부분
순진한 구현의 비용
60fps에서 캐릭터 30개, 총알 100개가 상호 충돌을 검사하면 프레임당 3,000번의 비교가 필요합니다. 이를 매 프레임 선형 탐색으로 처리하면 단일 스레드에서 금방 CPU가 녹습니다.
Flame 1.8부터는 내부적으로 Quadtree 기반 공간 분할을 지원합니다. `CollisionDetectionType.quadTree`로 전환하면 오브젝트 밀도가 높은 월드에서 CPU 사용량이 크게 떨어집니다. 2D 종스크롤 슈팅 기준으로 선형 탐색 대비 약 40~60% CPU 절감을 관측한 사례가 있습니다.
```dart class MyGame extends FlameGame with HasCollisionDetection { MyGame() : super( collisionDetection: QuadTreeCollisionDetection( mapDimensions: const Rect.fromLTWH(0, 0, 2048, 2048), maxObjects: 25, maxLevels: 10, ), ); } ```
Hitbox 모양 선택
| 모양 | 비용 | 사용 시점 | |---|---|---| | CircleHitbox | 가장 낮음 | 총알·아이템·파티클 | | RectangleHitbox | 낮음 | 플레이어·일반 적 | | PolygonHitbox | 높음 | 불규칙한 보스·지형 |
"픽셀 단위 정확한 충돌"에 집착하면 성능은 결국 무너집니다. 눈으로 봐서 어색하지 않을 정도의 가장 단순한 모양을 고르는 게 실전에서는 정답에 가깝습니다.
---
Sprite Sheet와 애니메이션
Draw Call을 줄이는 이유
모바일 GPU에서 가장 비싼 연산은 개별 Draw Call이 아니라 텍스처 바인딩 전환입니다. 같은 텍스처를 여러 번 그리는 것은 저렴하지만, 매번 다른 텍스처를 넘나들면 병목이 생깁니다.
해법은 Sprite Sheet(텍스처 아틀라스)입니다. 캐릭터의 Idle·Walk·Attack 애니메이션 프레임을 한 장의 큰 이미지로 묶고, UV 좌표만 바꿔 가며 동일 텍스처에서 잘라 씁니다.
```dart final sheet = await gameRef.loadSpriteSheet( 'hero_atlas.png', srcSize: Vector2(64, 64), ); final walkAnimation = sheet.createAnimation( row: 1, stepTime: 0.08, to: 8, ); ```
대규모 장면에서 배경·타일·UI 아이콘도 한두 개의 아틀라스로 통합하면, 저사양 안드로이드 기기에서도 60fps를 안정적으로 유지할 수 있습니다.
타일맵과 Tiled
`flame_tiled` 패키지는 오픈소스 에디터 Tiled의 `.tmx`/`.tsx` 포맷을 그대로 읽어들입니다. 레벨 디자이너가 Tiled로 맵을 찍으면 코드는 한 줄로 로드됩니다.
```dart final map = await TiledComponent.load('level1.tmx', Vector2.all(32)); add(map); ```
이 구조 덕분에 디자이너와 개발자의 작업이 완전히 분리되고, 레벨 반복(iteration) 속도가 극적으로 빨라집니다.
---
게임 루프와 프레임 예산
16.6ms를 어떻게 나눌 것인가
60fps 게임 루프는 매 프레임 16.6ms의 예산을 가집니다. 이 시간을 대략 다음처럼 분배하는 것이 보편적입니다.
| 단계 | 권장 예산 | |---|---| | 입력 처리 | 0.5ms | | 게임 로직 업데이트 | 3–5ms | | 충돌 판정 | 2–4ms | | 렌더 준비 | 3–5ms | | GPU 제출·여유 | 2–4ms |
Flutter DevTools의 Performance 탭에서 프레임별 타임라인을 확인할 수 있습니다. "Raster thread"가 16ms를 초과한다면 렌더 쪽이 병목, "UI thread"가 초과한다면 로직 쪽이 병목입니다.
오브젝트 풀링
총알·파티클처럼 짧게 살고 빈번히 생성/파괴되는 객체는 풀링(pooling)이 필수입니다. `Component.removeFromParent()`는 메모리를 바로 해제하지 않고 GC에 의존하기 때문에, 피크 상황에서 프레임 드랍을 유발할 수 있습니다.
```dart class BulletPool { final List<Bullet> _available = [];
Bullet acquire() => _available.isNotEmpty ? _available.removeLast() : Bullet();
void release(Bullet bullet) { bullet.reset(); _available.add(bullet); } } ```
---
오디오와 입력
flame_audio의 프리로드
오디오를 처음 재생할 때 파일 디코딩이 일어나 수십 밀리초의 스파이크가 생깁니다. 게임 시작 시 `FlameAudio.audioCache.loadAll([...])`으로 전부 프리로드하면 이 문제가 사라집니다.
입력 믹스인
- `TapCallbacks` — 탭 이벤트
- `DragCallbacks` — 드래그
- `HasKeyboardHandlerComponents` — 키보드
- `HasGameLoop` / `HasGyroscope` — 모바일 센서
각 믹스인은 독립적이어서 플랫폼별 입력을 깔끔하게 분기할 수 있습니다.
---
배포와 수익화
번들 사이즈 줄이기
Flame은 코어 의존성이 작지만, 이미지·오디오·폰트를 그대로 번들링하면 APK가 금세 100MB를 넘습니다. 실전 팁:
- 이미지는 WebP로, 필요하면 Lossy 75%
- 오디오는 `.ogg` 권장 (`.mp3`보다 압축률 좋음)
- 사용하지 않는 폰트 subset 제거
- `--split-per-abi`로 ABI별 APK 분할
Google Mobile Ads
Flame 위에서도 `google_mobile_ads` 패키지로 광고를 붙일 수 있습니다. 다만 렌더 쓰레드와 네이티브 광고 뷰의 병목이 생길 수 있어, 광고 로드는 게임 루프 외부(타이틀 화면, 결과 화면)로 빼는 것이 좋습니다.
---
실전 권장 스택
작은 프로젝트 기준으로 다음 스택이 가장 안정적입니다.
- Flutter: 3.9+
- flame: ^1.18
- flame_audio: ^2.10
- flame_tiled: ^1.20
- 상태 관리: Riverpod 2.x
- CI: GitHub Actions + Fastlane
- Crash 수집: Firebase Crashlytics
---
한 줄로 정리하면
Flame은 Flutter 개발자가 2D 게임에 들어가는 가장 낮은 허들입니다. 언어(Dart), IDE, 빌드 시스템이 그대로고, 한 코드베이스로 iOS·Android·웹·데스크톱이 같이 빠집니다. 다만 프레임 예산과 메모리 관리는 프레임워크가 아니라 결국 아키텍처로 풀어야 합니다.
첫 프로젝트에서는 욕심을 줄이는 편이 낫습니다. 화면 하나, 한 종류의 적, 한 가지 스킬로 먼저 60fps를 편하게 붙잡아 두고, 그 위에 기능을 얹는 순서가 결과적으로 가장 빠릅니다. 저희도 사내 미니게임을 그 순서로 만들다 보니, 정작 게임 자체의 재미에 투자할 여유가 나왔습니다.