Flutter 튜토리얼 24편: Fragment Shader와 커스텀 그래픽

요약#

핵심 요지#

  • 문제 정의: 복잡한 그래픽 효과(그라데이션 노이즈, 물결 효과, 블러 등)를 CPU로 처리하면 성능이 저하된다.
  • 핵심 주장: Fragment Shader1를 사용하면 GPU에서 픽셀 단위 연산을 병렬로 처리해서 고성능 그래픽 효과를 구현할 수 있다.
  • 주요 근거: FragmentProgram2으로 GLSL 셰이더를 로드하고, setFloat()setImageSampler()로 유니폼 값을 전달한다.
  • 실무 기준: 셰이더 컴파일은 비용이 크므로 애니메이션 시작 전에 미리 로드(precache)해야 한다.
  • 한계: UBO, SSBO, 커스텀 varying은 지원하지 않으며, sampler2D만 사용 가능하다.

문서가 설명하는 범위#

  • GLSL 셰이더 파일 작성과 프로젝트 등록
  • FragmentProgram과 FragmentShader 사용법
  • 유니폼 변수와 텍스처 전달
  • CustomPainter와 셰이더 통합

읽는 시간: 15분 | 난이도: 고급


참고 자료#


문제 상황#

앱에서 고급 시각 효과를 구현할 때 CPU만으로는 한계가 있습니다.

GPU 가속이 필요한 상황#

문제 1: 픽셀 단위 그라데이션, 노이즈 → CPU 처리 시 프레임 드롭
문제 2: 실시간 블러, 왜곡 효과 → 매 프레임 이미지 처리 비용이 높음
문제 3: 물결, 파동 같은 수학적 효과 → 수백만 픽셀 계산 필요
문제 4: 게임이나 인터랙티브 그래픽 → 60fps 유지 어려움

해결 방법#

Flutter는 GLSL Fragment Shader를 지원합니다. GPU에서 픽셀 단위 연산을 병렬로 처리해서 고성능 그래픽 효과를 구현할 수 있습니다.

챕터 1: 셰이더 파일 생성과 등록#

Why#

NOTE

셰이더를 사용하려면 GLSL 파일을 작성하고 Flutter 프로젝트에 등록해야 합니다.
빌드 시 셰이더가 컴파일되어 앱 번들에 포함됩니다.

.frag 파일 작성 → pubspec.yaml 등록 → 빌드 시 컴파일 → 런타임 로드

What#

NOTE

Flutter는 GLSL ES 1.0 호환 문법을 사용하며, #version 460 core로 시작합니다.
Skia와 Impeller 백엔드 모두 지원합니다.

How#

TIP

프로젝트 구조

my_app/
├── shaders/
│ ├── gradient.frag
│ └── ripple.frag
├── lib/
│ └── main.dart
└── pubspec.yaml

pubspec.yaml 등록

flutter:
shaders:
- shaders/gradient.frag
- shaders/ripple.frag

기본 셰이더 구조 (gradient.frag)

#version 460 core
// Flutter 런타임 헬퍼
#include <flutter/runtime_effect.glsl>
// 유니폼 변수 (Dart에서 전달)
uniform vec2 uSize; // 캔버스 크기
uniform float uTime; // 시간 (애니메이션용)
// 출력 색상
out vec4 fragColor;
void main() {
// 현재 픽셀 위치 (0 ~ uSize)
vec2 pos = FlutterFragCoord().xy;
// 정규화된 좌표 (0.0 ~ 1.0)
vec2 uv = pos / uSize;
// 그라데이션 색상 출력
fragColor = vec4(uv.x, uv.y, 0.5, 1.0);
}

Watch out#

WARNING

gl_FragCoord 대신 FlutterFragCoord()를 사용해야 합니다.
Flutter 헬퍼 함수는 백엔드 간 일관성을 보장합니다.

// 잘못된 방법 - 백엔드마다 결과가 다를 수 있음
vec2 pos = gl_FragCoord.xy;
// 올바른 방법
#include <flutter/runtime_effect.glsl>
vec2 pos = FlutterFragCoord().xy;

결론: GLSL 셰이더 파일을 작성하고 pubspec.yaml에 등록하면 Flutter에서 사용할 수 있습니다.


챕터 2: FragmentProgram으로 셰이더 로드#

Why#

NOTE

셰이더를 사용하려면 먼저 FragmentProgram으로 컴파일된 셰이더를 로드해야 합니다.
로드는 비동기 작업이며, 완료 후 FragmentShader 인스턴스를 생성할 수 있습니다.

graph LR A[FragmentProgram.fromAsset] --> B[셰이더 로드] B --> C[fragmentShader 생성] C --> D[유니폼 설정] D --> E[Paint에 적용]

What#

NOTE

FragmentProgram은 컴파일된 셰이더 프로그램을 나타냅니다.
fragmentShader() 메서드로 실제 렌더링에 사용할 FragmentShader 인스턴스를 생성합니다.

How#

TIP

셰이더 로드

import 'dart:ui' as ui;
class ShaderManager {
static ui.FragmentProgram? _gradientProgram;
static Future<void> loadShaders() async {
_gradientProgram = await ui.FragmentProgram.fromAsset(
'shaders/gradient.frag',
);
}
static ui.FragmentShader get gradientShader {
return _gradientProgram!.fragmentShader();
}
}

앱 시작 시 미리 로드

void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 셰이더 미리 로드
await ShaderManager.loadShaders();
runApp(const MyApp());
}

FutureBuilder로 로드 대기

class ShaderDemo extends StatelessWidget {
const ShaderDemo({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: ui.FragmentProgram.fromAsset('shaders/gradient.frag'),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text('셰이더 로드 실패: ${snapshot.error}'));
}
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return CustomPaint(
painter: GradientPainter(snapshot.data!),
size: Size.infinite,
);
},
);
}
}

Watch out#

WARNING

셰이더 컴파일은 Skia 백엔드에서 비용이 큽니다.
애니메이션 중에 처음 로드하면 프레임 드롭이 발생합니다.

// 잘못된 방법 - 애니메이션 중 로드
@override
void paint(Canvas canvas, Size size) async {
// 매 프레임 로드 시도 → 성능 저하
final program = await FragmentProgram.fromAsset('shaders/effect.frag');
}
// 올바른 방법 - 미리 로드
class MyPainter extends CustomPainter {
final FragmentShader shader; // 미리 로드된 셰이더
MyPainter(this.shader);
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = shader,
);
}
}

결론: FragmentProgram.fromAsset()으로 셰이더를 로드하고, 애니메이션 전에 미리 로드합니다.


챕터 3: 유니폼 변수 전달하기#

Why#

NOTE

셰이더에 동적 데이터(시간, 크기, 색상 등)를 전달하려면 유니폼 변수를 사용합니다.
Dart에서 setFloat()로 값을 설정하면 셰이더에서 해당 값을 읽을 수 있습니다.

Dart: shader.setFloat(0, value) → GLSL: uniform float uValue;

What#

NOTE

유니폼 변수는 선언 순서대로 인덱스가 할당됩니다.
float, vec2, vec3, vec4는 모두 setFloat()로 설정하며, 벡터는 각 컴포넌트를 개별 인덱스로 설정합니다.

How#

TIP

셰이더 유니폼 선언 (effect.frag)

#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize; // 인덱스 0, 1
uniform float uTime; // 인덱스 2
uniform vec4 uColor; // 인덱스 3, 4, 5, 6
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
// 시간에 따른 애니메이션
float wave = sin(uv.x * 10.0 + uTime) * 0.5 + 0.5;
fragColor = vec4(uColor.rgb * wave, uColor.a);
}

Dart에서 유니폼 설정

void setUniforms(
ui.FragmentShader shader,
Size size,
double time,
Color color,
) {
// vec2 uSize (인덱스 0, 1)
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
// float uTime (인덱스 2)
shader.setFloat(2, time);
// vec4 uColor (인덱스 3, 4, 5, 6)
// Flutter 색상은 premultiplied alpha로 변환
shader.setFloat(3, color.red / 255 * color.opacity);
shader.setFloat(4, color.green / 255 * color.opacity);
shader.setFloat(5, color.blue / 255 * color.opacity);
shader.setFloat(6, color.opacity);
}

유니폼 인덱스 계산 규칙

GLSL 타입인덱스 수예시
float1uniform float a; → 인덱스 0
vec22uniform vec2 b; → 인덱스 1, 2
vec33uniform vec3 c; → 인덱스 3, 4, 5
vec44uniform vec4 d; → 인덱스 6, 7, 8, 9

Watch out#

WARNING

색상은 premultiplied alpha 형식으로 전달해야 합니다.
Flutter의 Color는 straight alpha를 사용하므로 변환이 필요합니다.

// 잘못된 방법 - straight alpha
shader.setFloat(0, color.red / 255); // R
shader.setFloat(1, color.green / 255); // G
shader.setFloat(2, color.blue / 255); // B
shader.setFloat(3, color.opacity); // A
// 올바른 방법 - premultiplied alpha
shader.setFloat(0, color.red / 255 * color.opacity);
shader.setFloat(1, color.green / 255 * color.opacity);
shader.setFloat(2, color.blue / 255 * color.opacity);
shader.setFloat(3, color.opacity);

결론: 유니폼 변수는 선언 순서대로 인덱스를 할당하고, setFloat()로 값을 전달합니다.


챕터 4: CustomPainter와 셰이더 통합#

Why#

NOTE

셰이더를 화면에 그리려면 CustomPainter와 결합합니다.
Paint.shader 속성에 셰이더를 설정하고 Canvas.drawRect()로 영역을 채웁니다.

CustomPainter.paint() → Paint..shader = fragmentShader → canvas.drawRect()

What#

NOTE

CustomPainterpaint() 메서드에서 셰이더를 적용한 Paint로 도형을 그립니다.
애니메이션을 위해 repaint 리스너를 사용합니다.

How#

TIP

기본 셰이더 페인터

import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class ShaderPainter extends CustomPainter {
final ui.FragmentShader shader;
final double time;
ShaderPainter(this.shader, this.time);
@override
void paint(Canvas canvas, Size size) {
// 유니폼 설정
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
shader.setFloat(2, time);
// 셰이더로 사각형 채우기
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = shader,
);
}
@override
bool shouldRepaint(ShaderPainter oldDelegate) {
return time != oldDelegate.time;
}
}

애니메이션과 결합

class AnimatedShaderWidget extends StatefulWidget {
final ui.FragmentProgram program;
const AnimatedShaderWidget({super.key, required this.program});
@override
State<AnimatedShaderWidget> createState() => _AnimatedShaderWidgetState();
}
class _AnimatedShaderWidgetState extends State<AnimatedShaderWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late ui.FragmentShader _shader;
@override
void initState() {
super.initState();
_shader = widget.program.fragmentShader();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 10),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: ShaderPainter(
_shader,
_controller.value * 10, // 0 ~ 10 시간값
),
size: Size.infinite,
);
},
);
}
}

flutter_shaders 패키지 사용

import 'package:flutter_shaders/flutter_shaders.dart';
class SimpleShaderWidget extends StatelessWidget {
const SimpleShaderWidget({super.key});
@override
Widget build(BuildContext context) {
return ShaderBuilder(
assetKey: 'shaders/gradient.frag',
(context, shader, child) {
return AnimatedSampler(
(image, size, canvas) {
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
canvas.drawRect(
Offset.zero & size,
Paint()..shader = shader,
);
},
child: child!,
);
},
child: const SizedBox.expand(),
);
}
}

Watch out#

WARNING

FragmentShader 인스턴스를 매 프레임 새로 생성하면 성능이 저하됩니다.
인스턴스를 재사용하고 유니폼만 업데이트하세요.

// 잘못된 방법 - 매 프레임 새 인스턴스
@override
void paint(Canvas canvas, Size size) {
final shader = _program.fragmentShader(); // 매번 생성
shader.setFloat(0, time);
canvas.drawRect(...);
}
// 올바른 방법 - 인스턴스 재사용
final _shader = _program.fragmentShader(); // 한 번만 생성
@override
void paint(Canvas canvas, Size size) {
_shader.setFloat(0, time); // 유니폼만 업데이트
canvas.drawRect(...);
}

결론: CustomPainter에서 셰이더를 Paint에 적용하고, 인스턴스를 재사용해서 성능을 최적화합니다.


챕터 5: 이미지 텍스처 사용하기#

Why#

NOTE

셰이더에서 이미지를 샘플링하면 왜곡, 블러, 색상 보정 등의 효과를 적용할 수 있습니다.
sampler2D 유니폼으로 이미지를 전달합니다.

Dart: shader.setImageSampler(0, image) → GLSL: uniform sampler2D uTexture;

What#

NOTE

setImageSampler()dart:ui.Image를 셰이더에 전달합니다.
셰이더에서 texture() 함수로 픽셀 색상을 샘플링합니다.

How#

TIP

텍스처 샘플링 셰이더 (distort.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;
// OpenGLES에서 Y 좌표 뒤집기
#ifdef IMPELLER_TARGET_OPENGLES
uv.y = 1.0 - uv.y;
#endif
// 물결 왜곡 효과
float distortion = sin(uv.y * 20.0 + uTime * 3.0) * 0.02;
uv.x += distortion;
// 텍스처 샘플링
fragColor = texture(uTexture, uv);
}

Dart에서 이미지 전달

import 'dart:ui' as ui;
class TextureShaderPainter extends CustomPainter {
final ui.FragmentShader shader;
final ui.Image image;
final double time;
TextureShaderPainter(this.shader, this.image, this.time);
@override
void paint(Canvas canvas, Size size) {
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
shader.setFloat(2, time);
shader.setImageSampler(0, image); // 텍스처 전달
canvas.drawRect(
Rect.fromLTWH(0, 0, size.width, size.height),
Paint()..shader = shader,
);
}
@override
bool shouldRepaint(TextureShaderPainter oldDelegate) => true;
}

이미지 로드

Future<ui.Image> loadImage(String assetPath) async {
final data = await rootBundle.load(assetPath);
final codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
final frame = await codec.getNextFrame();
return frame.image;
}

위젯에서 통합

class TextureShaderWidget extends StatefulWidget {
const TextureShaderWidget({super.key});
@override
State<TextureShaderWidget> createState() => _TextureShaderWidgetState();
}
class _TextureShaderWidgetState extends State<TextureShaderWidget>
with SingleTickerProviderStateMixin {
ui.FragmentProgram? _program;
ui.Image? _image;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
)..repeat();
_loadResources();
}
Future<void> _loadResources() async {
final program = await ui.FragmentProgram.fromAsset('shaders/distort.frag');
final image = await loadImage('assets/images/photo.jpg');
setState(() {
_program = program;
_image = image;
});
}
@override
Widget build(BuildContext context) {
if (_program == null || _image == null) {
return const Center(child: CircularProgressIndicator());
}
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: TextureShaderPainter(
_program!.fragmentShader(),
_image!,
_controller.value * 10,
),
size: Size.infinite,
);
},
);
}
}

Watch out#

WARNING

sampler2D 인덱스는 float 인덱스와 별도로 관리됩니다.
첫 번째 텍스처는 항상 setImageSampler(0, image)입니다.

// 셰이더
uniform vec2 uSize; // float 인덱스 0, 1
uniform float uTime; // float 인덱스 2
uniform sampler2D uTex1; // sampler 인덱스 0
uniform sampler2D uTex2; // sampler 인덱스 1
// Dart
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
shader.setFloat(2, time);
shader.setImageSampler(0, texture1); // 첫 번째 텍스처
shader.setImageSampler(1, texture2); // 두 번째 텍스처

결론: setImageSampler()로 이미지를 셰이더에 전달하고, texture() 함수로 샘플링합니다.


챕터 6: 실용적인 셰이더 예제#

Why#

NOTE

셰이더의 가능성을 이해하려면 실제 예제를 통해 학습하는 것이 효과적입니다.
그라데이션, 노이즈, 원형 효과 등 자주 사용되는 패턴을 살펴봅니다.

기본 패턴 → 조합 → 복잡한 효과

What#

NOTE

수학 함수(sin, cos, fract)와 거리 계산을 조합해서 다양한 시각 효과를 만들 수 있습니다.
The Book of Shaders와 Shadertoy가 좋은 학습 리소스입니다.

How#

TIP

방사형 그라데이션

#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform vec2 uCenter;
uniform vec4 uInnerColor;
uniform vec4 uOuterColor;
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
vec2 center = uCenter / uSize;
float dist = distance(uv, center);
float gradient = smoothstep(0.0, 0.5, dist);
fragColor = mix(uInnerColor, uOuterColor, gradient);
}

물결 효과

#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform float uTime;
uniform vec2 uCenter;
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
vec2 center = uCenter / uSize;
float dist = distance(uv, center);
float ripple = sin(dist * 30.0 - uTime * 5.0);
ripple = ripple * 0.5 + 0.5; // 0 ~ 1로 정규화
// 거리에 따라 감쇠
ripple *= 1.0 - smoothstep(0.0, 0.5, dist);
fragColor = vec4(ripple, ripple, ripple, 1.0);
}

노이즈 패턴

#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform float uTime;
out vec4 fragColor;
// 랜덤 함수
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
// 그리드 기반 노이즈
vec2 grid = floor(uv * 20.0 + uTime);
float noise = random(grid);
fragColor = vec4(vec3(noise), 1.0);
}

색상 보정 필터

#version 460 core
#include <flutter/runtime_effect.glsl>
uniform vec2 uSize;
uniform sampler2D uTexture;
uniform float uBrightness; // -1 ~ 1
uniform float uContrast; // 0 ~ 2
uniform float uSaturation; // 0 ~ 2
out vec4 fragColor;
void main() {
vec2 uv = FlutterFragCoord().xy / uSize;
#ifdef IMPELLER_TARGET_OPENGLES
uv.y = 1.0 - uv.y;
#endif
vec4 color = texture(uTexture, uv);
// 밝기
color.rgb += uBrightness;
// 대비
color.rgb = (color.rgb - 0.5) * uContrast + 0.5;
// 채도
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(gray), color.rgb, uSaturation);
fragColor = color;
}

Watch out#

WARNING

복잡한 셰이더는 저사양 기기에서 성능 문제가 발생할 수 있습니다.
반복문과 조건문을 최소화하고, 계산을 단순화하세요.

// 비효율적 - 반복문 사용
float sum = 0.0;
for (int i = 0; i < 100; i++) {
sum += someCalculation(i);
}
// 효율적 - 수학적 단순화
float sum = (n * (n + 1)) / 2.0; // 공식 사용

항상 실제 기기에서 성능을 테스트하세요.

결론: 수학 함수와 거리 계산을 조합해서 다양한 GPU 가속 시각 효과를 만들 수 있습니다.


한계#

Flutter의 Fragment Shader는 강력하지만 몇 가지 제약이 있습니다.

  • UBO/SSBO 미지원: Uniform Buffer Object와 Shader Storage Buffer Object를 사용할 수 없습니다.
  • sampler2D만 지원: 다른 샘플러 타입(sampler3D, samplerCube 등)은 지원하지 않습니다.
  • varying 미지원: 커스텀 varying 입력을 사용할 수 없습니다.
  • 정밀도 힌트 무시: Skia 백엔드는 precision 힌트를 무시합니다.
  • 컴파일 비용: 첫 사용 시 컴파일 시간이 필요합니다.

Footnotes#

  1. Fragment Shader: GPU에서 실행되는 프로그램으로, 각 픽셀의 색상을 계산한다. GLSL 언어로 작성한다.

  2. FragmentProgram: Flutter에서 컴파일된 셰이더 프로그램을 나타내는 클래스다. fromAsset()으로 로드한다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 24편: Fragment Shader와 커스텀 그래픽
https://moodturnpost.net/posts/flutter/flutter-fragment-shader/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차