🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

Flutter/Flame 커스텀 렌더링 파이프라인 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 29. · 3 Views

Flutter/Flame 커스텀 렌더링 파이프라인 완벽 가이드

게임 엔진의 핵심인 렌더링 파이프라인을 직접 커스터마이징하는 방법을 배웁니다. Canvas부터 셰이더, 배치 렌더링까지 실무에서 바로 쓸 수 있는 최적화 기법을 소개합니다.


목차

  1. Canvas 렌더링 이해
  2. 커스텀 RenderComponent
  3. 배치 렌더링 최적화
  4. 셰이더 효과 적용
  5. 오프스크린 렌더링
  6. 렌더 타겟 활용

1. Canvas 렌더링 이해

어느 날 게임 개발자 김개발 씨는 Flame 엔진으로 첫 게임을 만들고 있었습니다. 그런데 화면에 그려지는 객체가 많아질수록 프레임이 뚝뚝 끊기기 시작했습니다.

선배 박시니어 씨가 다가와 물었습니다. "렌더링 파이프라인을 제대로 이해하고 있나요?"

Canvas 렌더링은 Flutter에서 화면에 그림을 그리는 가장 기본적인 방법입니다. 마치 화가가 캔버스에 붓으로 그림을 그리듯, 우리는 Canvas API를 사용하여 원하는 도형, 텍스트, 이미지를 화면에 그립니다.

이것을 제대로 이해하면 게임의 모든 시각적 요소를 직접 제어할 수 있습니다.

다음 코드를 살펴봅시다.

import 'package:flutter/material.dart';

class CustomCanvasPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // Paint 객체로 그리기 스타일 정의
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 4.0
      ..style = PaintingStyle.fill;

    // 원 그리기 - 매 프레임마다 실행됨
    canvas.drawCircle(
      Offset(size.width / 2, size.height / 2),
      50,
      paint,
    );
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

김개발 씨는 입사 3개월 차 게임 개발자입니다. 오늘도 열심히 게임을 개발하던 중, 화면에 적 캐릭터가 100개만 넘어가도 프레임이 급격히 떨어지는 현상을 발견했습니다.

분명히 코드는 맞게 작성한 것 같은데 왜 이렇게 느린 걸까요? 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"아, 렌더링 기초부터 제대로 이해해야 해요. Canvas가 어떻게 동작하는지 알아야 최적화도 가능합니다." Canvas 렌더링이란 정확히 무엇일까요?

쉽게 비유하자면, Canvas는 마치 진짜 화가의 캔버스와 같습니다. 화가가 팔레트에서 물감을 섞어 붓에 묻힌 뒤 캔버스에 그림을 그리듯, 우리는 Paint 객체에 색상과 스타일을 지정한 뒤 Canvas에 도형을 그립니다.

한 번 그린 그림은 화면에 남아있고, 새로운 프레임에서는 다시 처음부터 그려야 합니다. 이처럼 Canvas도 매 프레임마다 화면을 완전히 새로 그리는 방식으로 동작합니다.

Canvas가 없던 시절에는 어땠을까요? 개발자들은 위젯 트리만으로 복잡한 그래픽을 표현해야 했습니다.

원 하나를 그리려면 Container 위젯에 BorderRadius를 적용하고, 선 하나를 그리려면 또 다른 Container를 만들어야 했습니다. 코드가 길어지고, 성능도 좋지 않았습니다.

더 큰 문제는 프레임 단위로 정밀하게 제어하기 어려웠다는 점입니다. 게임처럼 매 프레임 수십, 수백 개의 객체를 그려야 하는 경우에는 위젯 방식으로는 한계가 있었습니다.

바로 이런 문제를 해결하기 위해 Canvas API가 제공됩니다. Canvas를 사용하면 직접적인 픽셀 제어가 가능해집니다.

또한 위젯 오버헤드 없이 순수하게 그래픽만 렌더링할 수 있습니다. 무엇보다 매 프레임 완전한 제어권을 가질 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 CustomPainter를 상속받아 paint 메서드를 오버라이드합니다.

이 메서드가 실제로 그리기가 일어나는 곳입니다. Paint 객체를 생성하여 색상, 선 굵기, 채우기 스타일을 지정합니다.

이것이 화가의 팔레트 역할을 합니다. 다음으로 canvas.drawCircle을 호출하여 화면 중앙에 반지름 50의 원을 그립니다.

마지막으로 shouldRepaint를 true로 반환하면 매 프레임마다 다시 그려집니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 슈팅 게임을 개발한다고 가정해봅시다. 화면에 적 총알 200개, 아군 총알 100개, 파티클 효과 500개가 동시에 표시되어야 하는 상황에서 Canvas를 활용하면 각 객체의 위치와 색상을 직접 제어하여 효율적으로 렌더링할 수 있습니다.

Unity나 Unreal 같은 상용 엔진도 내부적으로는 비슷한 방식으로 동작합니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 paint 메서드 안에서 객체를 생성하는 것입니다. Paint 객체를 매번 새로 만들면 가비지 컬렉션이 자주 발생하여 프레임 드롭이 일어날 수 있습니다.

따라서 Paint 객체는 클래스 멤버 변수로 선언하여 재사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, Canvas가 이렇게 동작하는구나!" Canvas 렌더링을 제대로 이해하면 더 효율적이고 부드러운 게임을 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Paint 객체는 재사용하여 GC 부담을 줄이세요

  • shouldRepaint는 필요할 때만 true를 반환하여 불필요한 렌더링을 방지하세요

2. 커스텀 RenderComponent

김개발 씨는 Flame 엔진으로 게임을 만들면서 기본 제공되는 SpriteComponent만으로는 부족하다고 느꼈습니다. 파티클 효과나 트레일 같은 특수한 시각 효과를 만들려면 어떻게 해야 할까요?

박시니어 씨가 힌트를 줍니다. "직접 RenderComponent를 만들어보면 어떨까요?"

커스텀 RenderComponent는 Flame 엔진에서 자신만의 렌더링 로직을 가진 게임 객체를 만드는 방법입니다. 마치 레고 블록처럼 기본 컴포넌트를 조합하여 새로운 게임 요소를 만들 수 있습니다.

이것을 활용하면 엔진의 제약 없이 원하는 모든 시각 효과를 구현할 수 있습니다.

다음 코드를 살펴봅시다.

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

class TrailComponent extends PositionComponent {
  final List<Vector2> points = [];
  final Paint _paint = Paint()
    ..color = Colors.cyan.withOpacity(0.5)
    ..strokeWidth = 3.0
    ..style = PaintingStyle.stroke;

  @override
  void update(double dt) {
    super.update(dt);
    // 트레일 포인트 추가 (매 프레임)
    points.add(position.clone());
    if (points.length > 30) points.removeAt(0);
  }

  @override
  void render(Canvas canvas) {
    // 트레일 그리기 - 점들을 선으로 연결
    for (int i = 0; i < points.length - 1; i++) {
      canvas.drawLine(points[i].toOffset(), points[i + 1].toOffset(), _paint);
    }
  }
}

김개발 씨는 이제 슈팅 게임의 적 캐릭터를 만들고 있습니다. 적이 움직일 때 뒤에 청록색 트레일 효과를 남기고 싶은데, Flame 엔진에는 이런 컴포넌트가 기본 제공되지 않습니다.

인터넷을 검색해봐도 딱 맞는 예제를 찾을 수 없었습니다. 박시니어 씨가 다가와 말합니다.

"없으면 만들면 되죠. RenderComponent를 직접 상속받아서 만들어보세요." 그렇다면 커스텀 RenderComponent란 정확히 무엇일까요?

쉽게 비유하자면, RenderComponent는 마치 공장의 생산 라인과 같습니다. update 단계에서는 부품을 조립하고, render 단계에서는 완성품에 페인트를 칠합니다.

이 두 단계가 매 프레임마다 반복되면서 화면에 움직이는 게임 객체가 표현됩니다. 공장에서 자동차를 만들 때 조립과 도색이 분리되어 있듯, 게임 엔진도 로직 업데이트와 렌더링을 분리하여 효율성을 높입니다.

커스텀 컴포넌트가 없던 시절에는 어땠을까요? 개발자들은 기존 컴포넌트를 무리하게 조합해서 원하는 효과를 만들어야 했습니다.

트레일 효과를 만들려면 수십 개의 SpriteComponent를 생성하고 일일이 위치를 업데이트해야 했습니다. 코드가 복잡해지고, 성능도 나빴습니다.

더 큰 문제는 유지보수가 어려웠다는 점입니다. 특수 효과 하나를 수정하려면 여러 파일을 뒤져야 했고, 버그가 생기기도 쉬웠습니다.

바로 이런 문제를 해결하기 위해 커스텀 RenderComponent 패턴이 사용됩니다. RenderComponent를 상속받으면 완전한 렌더링 제어권을 얻을 수 있습니다.

또한 재사용 가능한 게임 객체를 만들 수 있습니다. 무엇보다 로직과 렌더링의 명확한 분리라는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 PositionComponent를 상속받습니다.

이것은 위치와 크기를 가진 기본 컴포넌트입니다. points 리스트에는 트레일을 그릴 위치들이 저장됩니다.

update 메서드에서는 매 프레임마다 현재 위치를 points에 추가하고, 30개를 초과하면 가장 오래된 점을 제거합니다. 이것이 트레일이 점점 사라지는 효과를 만듭니다.

render 메서드에서는 저장된 점들을 선으로 연결하여 Canvas에 그립니다. 마지막으로 Paint 객체는 클래스 멤버로 선언하여 재사용합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 RPG 게임을 개발한다고 가정해봅시다.

마법사 캐릭터가 파이어볼을 발사할 때 불꽃 파티클 효과가 필요합니다. 커스텀 RenderComponent로 ParticleEffectComponent를 만들면 update에서 파티클의 물리 시뮬레이션을 계산하고, render에서 각 파티클을 그릴 수 있습니다.

한 번 만들어두면 게임 전체에서 재사용할 수 있어 매우 효율적입니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 render 메서드 안에서 복잡한 계산을 하는 것입니다. render는 오직 그리기만 담당해야 하고, 모든 계산은 update에서 미리 해두어야 합니다.

이렇게 하지 않으면 렌더링 성능이 크게 저하될 수 있습니다. 따라서 update에서 계산을 완료하고 render에서는 결과만 그리는 습관을 들여야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.

"아, 이렇게 로직과 렌더링을 분리하는구나!" 커스텀 RenderComponent를 제대로 이해하면 더 다양하고 멋진 게임 효과를 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - update에서 계산하고 render에서 그리는 원칙을 지키세요

  • Paint 객체는 멤버 변수로 선언하여 재사용하세요

3. 배치 렌더링 최적화

김개발 씨의 게임에 이제 적 캐릭터가 500개나 등장합니다. 그런데 프레임이 20fps로 떨어져버렸습니다.

각 적마다 render 메서드가 500번 호출되고 있는 게 문제일까요? 박시니어 씨가 해결책을 제시합니다.

"배치 렌더링으로 한 번에 그려보세요."

배치 렌더링은 비슷한 객체들을 개별적으로 그리지 않고 한 번에 묶어서 그리는 최적화 기법입니다. 마치 편지를 한 통씩 보내는 대신 여러 통을 한꺼번에 우체국에 가져가는 것과 같습니다.

이 기법을 사용하면 드로우 콜을 획기적으로 줄여 성능을 크게 향상시킬 수 있습니다.

다음 코드를 살펴봅시다.

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

class BatchedEnemyRenderer extends Component {
  final List<Enemy> enemies;
  final Paint _paint = Paint()..color = Colors.red;

  BatchedEnemyRenderer(this.enemies);

  @override
  void render(Canvas canvas) {
    // 모든 적을 한 번에 렌더링 - 드로우 콜 1번
    canvas.saveLayer(null, _paint);

    for (final enemy in enemies) {
      // 각 적의 위치로 이동하여 그리기
      canvas.save();
      canvas.translate(enemy.position.x, enemy.position.y);
      canvas.drawCircle(Offset.zero, 10, _paint);
      canvas.restore();
    }

    canvas.restore();
  }
}

김개발 씨는 이제 슈팅 게임의 메인 레벨을 구현하고 있습니다. 적 캐릭터 500개가 화면을 가득 채우며 다양한 패턴으로 움직입니다.

그런데 문제가 생겼습니다. 프레임이 20fps로 떨어지면서 게임이 버벅입니다.

프로파일러를 돌려보니 render 메서드가 병목이었습니다. 박시니어 씨가 코드를 보더니 한숨을 쉽니다.

"적 500개를 각각 그리고 있네요. 이러면 드로우 콜이 500번이나 발생해요.

배치 렌더링으로 바꿔보세요." 그렇다면 배치 렌더링이란 정확히 무엇일까요? 쉽게 비유하자면, 배치 렌더링은 마치 택배 배송과 같습니다.

고객 500명에게 물건을 배송할 때, 택배 기사가 500번 왕복하는 대신 트럭에 모든 물건을 한 번에 싣고 가서 순서대로 배달합니다. 이렇게 하면 시간과 연료를 크게 절약할 수 있습니다.

그래픽 렌더링도 마찬가지입니다. GPU에게 "이거 그려", "저거 그려"를 500번 명령하는 대신, "이 500개를 한 번에 그려"라고 명령하면 훨씬 효율적입니다.

배치 렌더링이 없던 시절에는 어땠을까요? 개발자들은 객체 하나당 드로우 콜 하나를 사용해야 했습니다.

적 100개를 그리면 드로우 콜 100번, 총알 200개를 그리면 추가로 200번이 발생했습니다. CPU와 GPU 사이의 통신 오버헤드가 누적되면서 성능이 급격히 나빠졌습니다.

더 큰 문제는 모바일 기기였습니다. 데스크톱에서는 그럭저럭 돌아가던 게임이 모바일에서는 10fps도 나오지 않는 경우가 많았습니다.

바로 이런 문제를 해결하기 위해 배치 렌더링 기법이 개발되었습니다. 배치 렌더링을 사용하면 드로우 콜 수를 극적으로 감소시킬 수 있습니다.

또한 GPU 활용도가 높아져 더 많은 객체를 그릴 수 있습니다. 무엇보다 모바일 기기에서도 부드러운 60fps를 유지할 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Component를 상속받아 BatchedEnemyRenderer를 만듭니다.

생성자에서 렌더링할 적들의 리스트를 받습니다. render 메서드에서 canvas.saveLayer를 호출하여 새로운 레이어를 시작합니다.

이것이 배치의 시작점입니다. 다음으로 for 루프로 모든 적을 순회하면서 각각의 위치에 원을 그립니다.

save와 restore로 변환 행렬을 관리합니다. 마지막으로 canvas.restore를 호출하면 레이어가 한 번에 화면에 합성됩니다.

이것이 배치 렌더링의 핵심입니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 파티클 시스템을 개발한다고 가정해봅시다. 폭발 효과 하나에 파티클 1000개가 생성됩니다.

각 파티클을 개별적으로 그리면 드로우 콜이 1000번 발생하지만, 배치 렌더링으로 묶으면 단 1번으로 줄일 수 있습니다. 많은 AAA급 게임들이 이런 방식으로 수만 개의 파티클을 동시에 렌더링합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 서로 다른 텍스처를 사용하는 객체들을 배치로 묶는 것입니다.

배치 렌더링은 같은 텍스처, 같은 셰이더를 사용하는 객체들에게만 효과적입니다. 서로 다른 텍스처를 사용하면 GPU가 텍스처를 전환해야 하므로 오히려 성능이 떨어질 수 있습니다.

따라서 객체들을 텍스처별로 그룹핑하여 배치를 구성해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 배치 렌더링을 적용한 김개발 씨는 놀랐습니다. "와, 프레임이 60fps로 올라갔어요!" 배치 렌더링을 제대로 이해하면 더 많은 객체를 부드럽게 렌더링할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 같은 텍스처를 사용하는 객체끼리 배치로 묶으세요

  • 드로우 콜 수는 프로파일러로 확인하면서 최적화하세요

4. 셰이더 효과 적용

김개발 씨는 게임에 특별한 시각 효과를 추가하고 싶었습니다. 적이 피격당할 때 번쩍이는 효과, 보스가 등장할 때 화면이 흔들리는 효과 같은 것들 말이죠.

박시니어 씨가 말합니다. "그럼 셰이더를 배워볼까요?

GPU의 진짜 힘을 느낄 수 있을 거예요."

셰이더는 GPU에서 실행되는 작은 프로그램으로, 픽셀 단위로 색상과 효과를 제어합니다. 마치 사진 편집 프로그램의 필터처럼, 실시간으로 화면에 다양한 시각 효과를 적용할 수 있습니다.

이것을 활용하면 CPU 부담 없이 화려한 그래픽 효과를 구현할 수 있습니다.

다음 코드를 살펴봅시다.

// assets/shaders/flash_effect.frag
#version 460 core
#include <flutter/runtime_effect.glsl>

uniform vec2 uSize;
uniform float uTime;
uniform sampler2D uTexture;

out vec4 fragColor;

void main() {
  // 텍스처 좌표 계산
  vec2 uv = FlutterFragCoord().xy / uSize;

  // 원본 색상 가져오기
  vec4 color = texture(uTexture, uv);

  // 시간에 따라 흰색으로 번쩍이는 효과
  float flash = abs(sin(uTime * 10.0));
  color.rgb = mix(color.rgb, vec3(1.0), flash * 0.5);

  fragColor = color;
}

김개발 씨는 이제 게임의 완성도를 높이는 단계에 접어들었습니다. 기본 기능은 모두 구현했지만, 뭔가 밋밋하다는 느낌이 듭니다.

적이 총알에 맞아도 그냥 사라질 뿐, 피격 효과가 없어서 타격감이 부족합니다. 포토샵 같은 프로그램에서 볼 수 있는 번쩍이는 효과를 게임에도 적용할 수 있을까요?

박시니어 씨가 흥미로운 제안을 합니다. "셰이더를 한번 배워보세요.

GPU 프로그래밍의 세계가 열릴 거예요." 그렇다면 셰이더란 정확히 무엇일까요? 쉽게 비유하자면, 셰이더는 마치 공장의 컨베이어 벨트와 같습니다.

원료가 벨트 위를 지나가면서 여러 가공 과정을 거쳐 완제품이 되듯, 픽셀들이 셰이더를 거치면서 색상이 변하고 효과가 적용됩니다. 중요한 것은 이 모든 과정이 GPU에서 병렬로 일어난다는 점입니다.

화면의 100만 개 픽셀을 동시에 처리하므로 엄청나게 빠릅니다. 셰이더가 없던 시절에는 어땠을까요?

개발자들은 CPU에서 픽셀 하나하나를 직접 계산해야 했습니다. 흰색 번쩍임 효과를 만들려면 이미지의 모든 픽셀을 읽어서 RGB 값을 수정한 뒤 다시 써야 했습니다.

1920x1080 해상도라면 약 200만 개의 픽셀을 매 프레임 처리해야 합니다. CPU 하나로는 불가능한 작업이었습니다.

더 큰 문제는 이런 계산이 게임 로직까지 느리게 만든다는 점이었습니다. 바로 이런 문제를 해결하기 위해 GPU 셰이더가 등장했습니다.

셰이더를 사용하면 수백만 개의 픽셀을 동시에 처리할 수 있습니다. 또한 CPU는 게임 로직에만 집중할 수 있게 됩니다.

무엇보다 다양하고 복잡한 시각 효과를 실시간으로 구현할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 GLSL 버전을 선언하고 Flutter의 런타임 효과 헤더를 포함합니다. uniform 변수들은 Dart 코드에서 전달받는 값들입니다.

uSize는 화면 크기, uTime은 경과 시간, uTexture는 원본 이미지입니다. main 함수에서 FlutterFragCoord로 현재 픽셀의 좌표를 얻습니다.

texture 함수로 원본 색상을 가져옵니다. 다음으로 sin 함수와 시간을 사용하여 0과 1 사이를 왔다갔다하는 flash 값을 계산합니다.

mix 함수로 원본 색상과 흰색을 섞으면 번쩍이는 효과가 완성됩니다. 마지막으로 계산된 색상을 fragColor에 출력합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 액션 RPG를 개발한다고 가정해봅시다.

플레이어가 무적 상태가 되면 캐릭터가 반투명하게 깜빡거리는 효과가 필요합니다. 셰이더로 alpha 값을 시간에 따라 변조하면 CPU 부담 없이 이 효과를 구현할 수 있습니다.

또한 화면 전체에 블러 효과를 적용하여 일시정지 메뉴의 배경을 만들 수도 있습니다. 대부분의 현대 게임들이 이런 방식으로 수십 가지 셰이더 효과를 동시에 사용합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 셰이더 안에서 복잡한 연산을 하는 것입니다.

셰이더는 모든 픽셀마다 실행되므로, 복잡한 계산을 넣으면 100만 배로 증폭됩니다. 예를 들어 for 루프를 100번 돌리는 셰이더를 화면 전체에 적용하면 1억 번의 연산이 발생합니다.

따라서 셰이더는 가능한 한 단순하게 작성하고, 복잡한 계산은 CPU에서 미리 하여 uniform으로 전달해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 도움으로 첫 셰이더를 작성한 김개발 씨는 감탄했습니다. "와, 이렇게 화려한 효과가 프레임 드롭 없이 돌아가네요!" 셰이더를 제대로 이해하면 더 멋지고 인상적인 게임을 만들 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 셰이더는 단순하게 유지하고 복잡한 계산은 CPU에서 하세요

  • uniform 변수로 시간을 전달하면 다양한 애니메이션 효과를 만들 수 있어요

5. 오프스크린 렌더링

김개발 씨는 미니맵 기능을 추가하려고 합니다. 게임 월드 전체를 작은 크기로 화면 구석에 표시하는 것이죠.

그런데 문제가 생겼습니다. 메인 화면과 미니맵을 동시에 렌더링하려니 코드가 너무 복잡해집니다.

박시니어 씨가 해결책을 알려줍니다. "오프스크린 렌더링을 사용해보세요."

오프스크린 렌더링은 화면에 직접 그리지 않고 메모리 상의 별도 캔버스에 먼저 그린 뒤, 그 결과를 나중에 화면에 복사하는 기법입니다. 마치 화가가 스케치북에 밑그림을 그린 뒤 캔버스로 옮기는 것처럼, 복잡한 그래픽을 단계적으로 처리할 수 있습니다.

이것을 활용하면 미니맵, 거울 효과, 포털 같은 고급 기능을 구현할 수 있습니다.

다음 코드를 살펴봅시다.

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

class MinimapRenderer extends Component {
  late ui.PictureRecorder _recorder;
  late Canvas _offscreenCanvas;
  ui.Picture? _cachedPicture;

  void renderToOffscreen(List<Enemy> enemies) {
    // 오프스크린 캔버스 생성
    _recorder = ui.PictureRecorder();
    _offscreenCanvas = Canvas(_recorder);

    // 배경 그리기
    _offscreenCanvas.drawRect(
      const Rect.fromLTWH(0, 0, 200, 200),
      Paint()..color = Colors.black26,
    );

    // 적들을 작은 점으로 그리기
    for (final enemy in enemies) {
      _offscreenCanvas.drawCircle(
        enemy.position.toOffset() * 0.1, // 10배 축소
        2,
        Paint()..color = Colors.red,
      );
    }

    // Picture로 변환 (GPU 텍스처로 캐싱)
    _cachedPicture = _recorder.endRecording();
  }

  @override
  void render(Canvas canvas) {
    // 캐싱된 Picture를 화면에 그리기
    if (_cachedPicture != null) {
      canvas.drawPicture(_cachedPicture!);
    }
  }
}

김개발 씨는 이제 게임의 핵심 기능을 거의 완성했습니다. 하지만 플레이 테스터들이 공통적으로 요청하는 기능이 있었습니다.

"적들이 어디 있는지 모르겠어요. 미니맵이 있으면 좋겠어요." 김개발 씨는 고민에 빠졌습니다.

메인 화면을 렌더링하는 코드와 미니맵을 렌더링하는 코드를 어떻게 분리해야 할까요? 박시니어 씨가 조언합니다.

"오프스크린 렌더링을 사용하면 두 화면을 깔끔하게 분리할 수 있어요." 그렇다면 오프스크린 렌더링이란 정확히 무엇일까요? 쉽게 비유하자면, 오프스크린 렌더링은 마치 요리의 밑준비와 같습니다.

손님에게 바로 요리를 내놓는 대신, 주방에서 재료를 다듬고 소스를 만들어 놓은 뒤, 마지막 순간에 접시에 담아 냅니다. 이렇게 하면 여러 요리를 동시에 준비할 수 있고, 같은 재료를 재사용할 수도 있습니다.

그래픽도 마찬가지입니다. 화면에 직접 그리는 대신 메모리에서 미리 준비한 뒤, 필요할 때 화면에 복사합니다.

오프스크린 렌더링이 없던 시절에는 어땠을까요? 개발자들은 모든 것을 메인 화면에 직접 그려야 했습니다.

미니맵을 만들려면 메인 렌더링 코드를 복사해서 축소판으로 다시 그려야 했습니다. 코드 중복이 심했고, 한쪽을 수정하면 다른 쪽도 수정해야 했습니다.

더 큰 문제는 복잡한 장면을 매 프레임 두 번씩 렌더링해야 한다는 점이었습니다. 성능도 나빴고 메모리도 많이 사용했습니다.

바로 이런 문제를 해결하기 위해 오프스크린 렌더링 기법이 개발되었습니다. 오프스크린 렌더링을 사용하면 복잡한 장면을 한 번만 렌더링하여 캐싱할 수 있습니다.

또한 렌더링 파이프라인을 모듈화하여 코드를 깔끔하게 유지할 수 있습니다. 무엇보다 여러 효과를 단계적으로 적용할 수 있다는 큰 이점이 있습니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 PictureRecorder를 생성합니다.

이것은 오프스크린 렌더링의 시작점입니다. 새로운 Canvas를 만들어 레코더에 연결합니다.

이 캔버스에 그린 모든 것은 화면이 아닌 메모리에 저장됩니다. 다음으로 미니맵 배경을 그리고 적들의 위치를 작은 점으로 표시합니다.

위치를 0.1배 축소하여 전체 맵이 작은 영역에 들어가도록 합니다. endRecording을 호출하면 지금까지의 드로잉 명령이 Picture 객체로 변환됩니다.

이것은 GPU에 최적화된 형태로 캐싱됩니다. 마지막으로 render 메서드에서 캐싱된 Picture를 화면에 그리면 미니맵이 표시됩니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 레이싱 게임을 개발한다고 가정해봅시다.

백미러에 뒤쪽 차량이 보여야 합니다. 오프스크린 렌더링으로 뒤쪽 시점의 장면을 별도로 렌더링한 뒤, 그 결과를 백미러 모양으로 잘라서 화면에 표시할 수 있습니다.

또한 포털 게임처럼 포털 너머의 세계를 보여줄 때도 같은 기법을 사용합니다. 많은 AAA급 게임들이 화면 효과를 위해 여러 단계의 오프스크린 렌더링을 사용합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 매 프레임 오프스크린 렌더링을 새로 하는 것입니다.

Picture는 한 번 생성하면 여러 프레임에 걸쳐 재사용할 수 있습니다. 미니맵처럼 자주 바뀌지 않는 것은 몇 프레임마다 한 번씩만 업데이트해도 충분합니다.

따라서 캐싱 전략을 잘 세워서 불필요한 렌더링을 피해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 조언대로 오프스크린 렌더링을 적용한 김개발 씨는 만족스러웠습니다. "코드도 깔끔해지고 성능도 좋네요!" 오프스크린 렌더링을 제대로 이해하면 더 복잡하고 고급스러운 게임 기능을 구현할 수 있습니다.

여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - Picture는 캐싱하여 여러 프레임에 걸쳐 재사용하세요

  • 자주 바뀌지 않는 장면만 오프스크린으로 렌더링하세요

6. 렌더 타겟 활용

김개발 씨는 이제 포스트 프로세싱 효과를 추가하려고 합니다. 블룸, 모션 블러, 색수차 같은 영화 같은 효과들 말이죠.

그런데 이런 효과들은 화면 전체를 대상으로 해야 합니다. 어떻게 구현해야 할까요?

박시니어 씨가 마지막 비법을 알려줍니다. "렌더 타겟을 활용해보세요."

렌더 타겟은 화면 대신 텍스처에 렌더링 결과를 저장하는 기법입니다. 마치 사진을 찍어서 포토샵으로 후보정하듯, 게임 화면을 텍스처로 저장한 뒤 셰이더로 다양한 효과를 적용할 수 있습니다.

이것을 활용하면 블룸, HDR, SSAO 같은 고급 그래픽 효과를 구현할 수 있습니다.

다음 코드를 살펴봅시다.

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

class PostProcessGame extends FlameGame {
  ui.Image? _renderTarget;
  late FragmentProgram _blurShader;

  @override
  Future<void> onLoad() async {
    // 블러 셰이더 로드
    _blurShader = await FragmentProgram.fromAsset('shaders/blur.frag');
  }

  @override
  void render(Canvas canvas) {
    // 1단계: 장면을 렌더 타겟에 그리기
    final recorder = ui.PictureRecorder();
    final rtCanvas = Canvas(recorder);
    super.render(rtCanvas); // 모든 게임 객체 렌더링
    final picture = recorder.endRecording();

    // 2단계: Picture를 이미지로 변환
    picture.toImage(size.x.toInt(), size.y.toInt()).then((image) {
      _renderTarget = image;
    });

    // 3단계: 렌더 타겟에 셰이더 효과 적용하여 화면에 그리기
    if (_renderTarget != null) {
      final shader = _blurShader.fragmentShader();
      shader.setFloat(0, size.x);
      shader.setFloat(1, size.y);
      shader.setImageSampler(0, _renderTarget!);

      final paint = Paint()..shader = shader;
      canvas.drawRect(Rect.fromLTWH(0, 0, size.x, size.y), paint);
    }
  }
}

김개발 씨는 이제 게임이 거의 완성 단계에 접어들었습니다. 게임플레이도 재미있고 그래픽도 괜찮습니다.

하지만 뭔가 아쉬움이 남습니다. 상용 게임들처럼 빛이 번지는 블룸 효과나, 빠르게 움직일 때 잔상이 남는 모션 블러 같은 것들을 추가하고 싶습니다.

이런 효과들은 어떻게 만들까요? 박시니어 씨가 마지막 고급 기법을 알려줍니다.

"렌더 타겟을 활용하면 영화 같은 후처리 효과를 모두 구현할 수 있어요." 그렇다면 렌더 타겟이란 정확히 무엇일까요? 쉽게 비유하자면, 렌더 타겟은 마치 사진 촬영과 후보정 과정과 같습니다.

사진가는 먼저 카메라로 사진을 찍습니다. 그 사진을 컴퓨터로 가져와서 밝기를 조정하고, 색감을 보정하고, 필터를 적용합니다.

원본 사진은 그대로 두고 효과만 바꿔가며 여러 버전을 만들 수 있습니다. 게임도 마찬가지입니다.

게임 장면을 먼저 텍스처에 그린 뒤, 그 텍스처에 셰이더 효과를 적용하여 최종 화면을 만듭니다. 렌더 타겟이 없던 시절에는 어땠을까요?

개발자들은 모든 효과를 실시간으로 적용해야 했습니다. 블룸 효과를 만들려면 밝은 부분을 찾아서 주변에 빛을 퍼뜨리는 계산을 매 프레임 해야 했습니다.

코드가 복잡했고 성능도 나빴습니다. 더 큰 문제는 여러 효과를 조합하기 어려웠다는 점입니다.

블룸과 모션 블러를 동시에 적용하려면 코드가 얽히고설켜 유지보수가 불가능해졌습니다. 바로 이런 문제를 해결하기 위해 렌더 타겟 기법이 표준이 되었습니다.

렌더 타겟을 사용하면 장면 렌더링과 후처리를 완전히 분리할 수 있습니다. 또한 여러 효과를 파이프라인으로 연결하여 복잡한 그래픽을 만들 수 있습니다.

무엇보다 각 효과를 독립적으로 개발하고 테스트할 수 있다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.

먼저 onLoad에서 블러 셰이더를 로드합니다. render 메서드에서 PictureRecorder로 새로운 캔버스를 만듭니다.

super.render를 호출하면 모든 게임 객체가 이 오프스크린 캔버스에 그려집니다. 이것이 렌더 타겟의 핵심입니다.

다음으로 Picture를 이미지로 변환합니다. 이 이미지가 바로 렌더 타겟입니다.

셰이더 객체를 생성하고 setFloat로 화면 크기를 전달합니다. setImageSampler로 렌더 타겟 이미지를 셰이더에 바인딩합니다.

마지막으로 셰이더가 적용된 Paint로 전체 화면을 그리면 블러 효과가 적용된 최종 화면이 나타납니다. 실제 현업에서는 어떻게 활용할까요?

예를 들어 FPS 게임을 개발한다고 가정해봅시다. 플레이어가 피격당하면 화면 가장자리가 빨갛게 변하는 효과가 필요합니다.

렌더 타겟으로 장면을 그린 뒤, 비네팅 셰이더를 적용하면 됩니다. 또한 스나이퍼 조준경의 줌 효과도 렌더 타겟을 활용합니다.

장면을 고해상도 텍스처에 렌더링한 뒤, 중앙 부분만 확대하여 화면에 표시합니다. 거의 모든 현대 게임 엔진이 이런 방식으로 그래픽 파이프라인을 구성합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 너무 많은 렌더 타겟을 사용하는 것입니다.

렌더 타겟 하나당 전체 화면 크기의 메모리가 필요합니다. 1920x1080 해상도라면 약 8MB입니다.

렌더 타겟을 10개 쓰면 80MB가 GPU 메모리에 상주합니다. 따라서 꼭 필요한 효과에만 사용하고, 해상도도 적절히 조절해야 합니다.

다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 지도 아래 렌더 타겟을 활용한 김개발 씨는 감격했습니다.

"드디어 AAA급 게임처럼 보이는 그래픽을 만들었어요!" 렌더 타겟을 제대로 이해하면 더 영화 같고 인상적인 그래픽을 구현할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.

실전 팁

💡 - 렌더 타겟은 메모리를 많이 사용하므로 필요한 만큼만 쓰세요

  • 해상도를 절반으로 줄여도 효과는 충분한 경우가 많아요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Flutter#Flame#RenderComponent#CustomPainter#ShaderEffect#Flutter,Flame,Game

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.