Flutter/Flame

Flame 엔진의 코어 아키텍처: GameLoop 소스 코드 딥다이브

smileDeveloper 2026. 9. 5. 12:14
반응형
SMALL

플러터(Flutter) 기반 2D 게임 개발의 표준으로 자리 잡은 Flame 엔진은 자체적인 렌더러를 바닥부터 새로 구축하는 대신, 플러터 네이티브의 심장인 Ticker와 Canvas를 영리하게 래핑한 우아한 프레임워크입니다. 단순히 완성된 패키지를 문서대로 가져다 쓰는 것을 넘어, 엔진이 어떻게 프레임을 쪼개고 수많은 객체를 렌더링하는지 공식 깃허브(GitHub) 소스 코드를 파헤치며 밑바닥 원리부터 확실하게 다져보겠습니다.


Flame 엔진을 선택해야 하는 기술적 이유

* 네이티브 인프라의 레버리지: 플러터의 강력한 위젯 시스템과 완벽하게 호환되어 인게임 화면(Canvas) 위에 네이티브 UI를 자연스럽게 오버레이할 수 있습니다. 단일 코드베이스를 사용해 iOS와 Android 양대 마켓에 동시 배포하여 개발 시간 대비 효율을 극대화합니다.
* 물리 엔진의 손쉬운 확장: 생태계 내에 준비된 forge2d 같은 가벼운 물리 엔진 패키지를 연동하면, 중력을 받아 물체가 통통 튀며 자연스럽게 떨어지고 쌓이는 복잡한 2D 물리 효과를 단 며칠의 주말 만에 손쉽게 구현해 낼 수 있습니다.
* 경량화된 아키텍처: 무거운 3D 상용 엔진과 달리 모바일 2D 환경에 극단적으로 최적화되어 있어, 불필요한 오버헤드 없이 쾌적한 60프레임 방어가 가능합니다.

이번 포스팅에서 해부할 3가지 코어 타겟

* GameLoop의 심장 박동: 플러터의 Ticker를 바탕으로 델타 타임(dt)을 추출하고, 기기 렉으로 인한 물리 엔진 터널링(벽 뚫기) 버그를 막아내는 엔진 내부의 방어 로직.
* FCS (Flame Component System): 부모-자식 트리 구조로 얽혀 updateTree와 renderTree 파이프라인을 엄격한 하향식(Top-Down)으로 실행하는 컴포넌트 라이프사이클의 비밀.
* World와 Camera의 분리 패러다임: 무한한 절대 좌표계(World)와 유저의 화면(Viewport)을 수학적으로 분리하여 줌(Zoom)과 카메라 트래킹을 단 한 줄로 처리하는 최신 렌더링 구조.

1. Flutter의 Ticker와 게임 루프의 시작

Flame 엔진이 플러터(Flutter) 생태계에서 독보적인 위치를 차지하는 이유는 자체적인 렌더링 엔진을 바닥부터 새로 짠 것이 아니라, 플러터 네이티브의 심장인 Ticker와 Canvas를 완벽하게 감싸 안았기 때문입니다. 공식 GitHub 저장소의 packages/flame/lib/src/game/game_loop.dart와 flame_game.dart를 뜯어보면 이 우아한 아키텍처를 확인할 수 있습니다.

Flame의 GameLoop는 플러터의 SchedulerBinding에서 제공하는 Ticker 클래스에 의존합니다. Ticker는 디스플레이의 주사율(일반적으로 60Hz 또는 120Hz)에 맞춰 매 프레임마다 콜백을 발생시킵니다.
루프가 시작되면 Ticker는 tick(Duration elapsed) 메서드를 끊임없이 호출하며, 이때 넘어오는 elapsed 파라미터는 앱이 시작된 후 경과된 총시간입니다. Flame은 이 값을 이용해 이전 프레임과 현재 프레임 사이의 시간 차이인 델타 타임(dt)을 계산합니다.

2. Delta Time (dt) 계산과 한계치 제어(Clamping)

소스 코드를 분석해 보면 가장 흥미로운 부분은 dt를 단순히 이전 시간과의 차이로만 두지 않는다는 점입니다.
* 스파이럴 오브 데스(Spiral of Death) 방지: 만약 OS 백그라운드 작업이나 일시적인 프리징으로 인해 프레임 렌더링에 1초가 걸렸다면, 물리 엔진은 물체가 1초 동안 이동한 거리를 한 번에 계산하려다 벽을 뚫고 지나가는 버그(터널링)를 냅니다.
* dt Clamping: 이를 방지하기 위해 Flame은 내부적으로 최대 dt 값을 제한(캡)합니다. 아무리 렉이 걸려도 게임 내 논리적 시간이 한 번에 비정상적으로 점프하지 않도록 방어 로직이 구현되어 있습니다.

3. 양대 산맥 파이프라인: updateTree와 renderTree

dt 계산이 끝나면 플레임은 매 프레임마다 정확히 두 가지 핵심 메서드를 순차적으로 실행합니다.
* updateTree(dt) (상태 업데이트):
   루트(Root) 게임 인스턴스부터 시작해 자식 컴포넌트 트리 전체를 순회하며 update(dt)를 호출합니다. 화면에 무언가를 그리기 전에 모든 수학적 연산과 로직이 여기서 처리됩니다. 플러터의 forge2d (Flame 엔진) 같은 가벼운 물리 엔진 패키지를 사용하면 물체가 자연스럽게 떨어지고 쌓이는 효과를 주말 내에 구현할 수 있는데, 이 패키지의 강체(Rigid Body) 충돌 및 중력 연산이 바로 이 update 파이프라인 안에서 처리됩니다.
* renderTree(Canvas canvas) (화면 렌더링):
   상태 업데이트가 완료되면 플러터의 Canvas 객체를 받아 컴포넌트들을 화면에 찍어냅니다. 컴포넌트의 위치(Position)나 앵커(Anchor)에 따라 canvas.save()와 canvas.restore()를 반복 호출하며 독립적인 좌표계를 유지한 채 스프라이트와 도형을 그려냅니다.

4. 컴포넌트 라이프사이클의 비밀

단순히 루프만 도는 것이 아닙니다. 깃허브 소스를 보면 컴포넌트가 트리에 추가될 때 즉시 화면에 나타나는 것이 아니라 onLoad() (비동기 에셋 로딩) -> onMount() (게임 트리에 완전히 부착됨) -> onGameResize() (화면 크기 계산) 순서의 엄격한 큐(Queue) 시스템을 거친다는 것을 알 수 있습니다. 이 비동기 큐잉 시스템 덕분에 수백 개의 객체가 동시에 생성되어도 프레임 드랍이 발생하지 않습니다.

FCS 트리와 GameLoop 실행 순서 검증 코드

import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

// 1. 최상위 루트 노드 (Game)
class MyGame extends FlameGame {
  @override
  Future<void> onLoad() async {
    print('[Root] onLoad 실행');
    // 부모 컴포넌트를 게임 트리에 추가
    add(ParentComponent());
  }
}

// 2. 부모 컴포넌트
class ParentComponent extends PositionComponent {
  @override
  Future<void> onLoad() async {
    print('  [Parent] onLoad 실행');
    // 자식 컴포넌트를 부모에게 종속시킴
    add(ChildComponent());
  }

  @override
  void update(double dt) {
    print('  [Parent] update 연산');
    super.update(dt); // 자식의 update를 호출하는 핵심 트리거
  }

  @override
  void render(Canvas canvas) {
    print('  [Parent] render 그리기');
    super.render(canvas);
  }
}

// 3. 자식 컴포넌트
class ChildComponent extends PositionComponent {
  @override
  void update(double dt) {
    print('    [Child] update 연산');
    super.update(dt);
  }

  @override
  void render(Canvas canvas) {
    print('    [Child] render 그리기');
    super.render(canvas);
  }
}

위 코드를 실행하면 플러터 디버그 콘솔에 일정한 패턴의 로그가 찍히며, Flame 내부 엔진의 엄격한 규칙을 증명합니다.
* 초기화 큐(Queue) 독립성: [Root] onLoad -> [Parent] onLoad 순서로 트리에 부착됩니다. 자식은 부모가 게임 트리에 완전히 자리 잡은 후에야 비동기 로딩 및 렌더링 파이프라인에 진입합니다.
* Update는 위에서 아래로 (Top-Down): 매 프레임 [Parent] update -> [Child] update 순으로 실행됩니다. 물리 연산 시, 부모의 좌표와 회전값이 먼저 갱신되어야 자식이 그 기준점을 바탕으로 자신의 절대 위치를 정확히 계산할 수 있기 때문입니다.
* Render도 위에서 아래로 (Painter's Algorithm): 상태 업데이트가 끝나면 [Parent] render -> [Child] render 순으로 화면에 그려집니다. 캔버스의 특성상 나중에 그려진 자식 객체가 부모 객체 위를 덮어쓰며 시각적으로 가장 앞쪽에 렌더링 됩니다.

플러터 기반 렌더링의 이점

* 별도의 외부 렌더링 파이프라인을 무겁게 구축할 필요 없이 플러터의 네이티브 Canvas를 그대로 활용합니다.
* Flutter를 사용해 iOS와 Android 양대 마켓에 동시 배포하여 시간 대비 효율을 극대화할 수 있습니다.

5. 월드(World)와 카메라(Camera)의 분리

1. 왜 카메라 컴포넌트가 필요할까?

과거 2D 게임 개발에서는 캐릭터가 이동할 때 캐릭터를 움직이는 대신 '배경을 반대로' 움직이는 꼼수를 쓰곤 했습니다. 하지만 Flame은 현대적인 World & Camera 아키텍처를 도입했습니다.
* World (월드): 물리적인 크기에 제한이 없는 무한한 게임 세계입니다. 모든 캐릭터와 적, 배경은 이곳에 배치됩니다.
* CameraComponent (카메라): 뷰파인더(Viewfinder)와 뷰포트(Viewport)를 통해 월드의 특정 부분을 비춰서 유저의 스마트폰 화면(Screen)에 뿌려주는 '렌즈' 역할을 합니다.
이 둘을 분리하면 화면을 확대/축소(Zoom)하거나, 화면이 흔들리는 연출(Camera Shake), 혹은 주인공을 카메라가 부드럽게 따라다니는 트래킹(Tracking) 구현이 수학적 계산 없이 단 한 줄의 코드로 끝납니다.

2. FCS 트리와 Camera를 결합한 최종 검증 코드

앞서 작성했던 FCS 로그 검증 코드에 World와 CameraComponent를 적용하여, 실제 실무에서 사용하는 최신 아키텍처로 업그레이드해 보겠습니다.

import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

// 1. 최상위 루트 노드 (Game)
class MyGame extends FlameGame {
  // 월드와 카메라를 명시적으로 선언
  late final World gameWorld;
  late final CameraComponent cameraComponent;

  @override
  Future<void> onLoad() async {
    print('[Root] onLoad 실행 - 월드와 카메라 세팅');
    
    // 월드 생성
    gameWorld = World();
    
    // 월드를 비추는 카메라 생성
    cameraComponent = CameraComponent(world: gameWorld);
    
    // 루트 트리에 카메라와 월드 부착
    addAll([cameraComponent, gameWorld]);

    // ✨ 핵심: 부모 컴포넌트를 Root가 아닌 'World'에 추가합니다!
    gameWorld.add(ParentComponent());
  }
}

// 2. 부모 컴포넌트 (이제 World의 자식입니다)
class ParentComponent extends PositionComponent {
  @override
  Future<void> onLoad() async {
    print('  [Parent] onLoad 실행 (World 내부에 위치)');
    // 자식을 부모에게 종속시킴
    add(ChildComponent());
  }

  @override
  void update(double dt) {
    // print('  [Parent] update');
    super.update(dt);
  }
  
  // ... (render 로직 동일)
}

// 3. 자식 컴포넌트
class ChildComponent extends PositionComponent {
  @override
  void update(double dt) {
    // 카메라가 부모(Parent)를 따라다니게 하고 싶다면?
    // 최상위 카메라에 접근해 follow를 지시할 수 있습니다.
    // (예: parent가 플레이어 캐릭터일 경우)
  }
  
  // ... (render 로직 동일)
}

3. 좌표계의 마법: Screen vs World

이 아키텍처를 도입하면 가장 헷갈리기 쉬운 좌표계 문제가 해결됩니다.
* PositionComponent를 gameWorld에 넣으면, 그 컴포넌트의 좌표는 월드 좌표(절대 좌표)가 됩니다.
* 조이스틱이나 남은 체력(HP) 바 같은 UI 위젯을 카메라의 뷰포트(Viewport)에 바로 붙이면, 캐릭터가 맵의 어디를 돌아다니든 UI는 화면의 항상 같은 자리에 고정됩니다(Screen 좌표).


Flame은 무거운 상용 3D 엔진과 달리 플러터 네이티브 인프라를 그대로 레버리지하는 극강의 효율을 자랑합니다. 1인 개발자가 한정된 주말 시간을 쪼개어 iOS와 Android 양대 마켓에 쾌적한 60프레임 게임을 동시 배포하기에 이보다 완벽한 도구는 없습니다. '어떻게 돌아가는지'를 완벽히 통제할 수 있게 된 지금, 우리는 터널링 버그나 프레임 저하 없이 게임의 코어 루프에만 온전히 집중할 수 있습니다.

다음 포스팅 주제는 렌더링 최적화와 드로우 콜(Draw Call) 깎기로 찾아오겠습니다. 뼈대를 세웠으니 이제 화려한 살을 붙일 차례입니다. 다음 주말에는 화면에 수백 개의 파티클과 스프라이트를 띄울 때 필연적으로 발생하는 프레임 저하를 막기 위해, 엔진 내부에서 드로우 콜을 극단적으로 깎아내는 ⁠SpriteBatch⁠ 최적화 기법과 에셋 파이프라인을 뜯어보겠습니다.

반응형
LIST