Flutter 튜토리얼 24편: Fragment Shader와 커스텀 그래픽
요약
핵심 요지
- 문제 정의: 복잡한 그래픽 효과(그라데이션 노이즈, 물결 효과, 블러 등)를 CPU로 처리하면 성능이 저하된다.
- 핵심 주장:
Fragment Shader1를 사용하면 GPU에서 픽셀 단위 연산을 병렬로 처리해서 고성능 그래픽 효과를 구현할 수 있다. - 주요 근거:
FragmentProgram2으로 GLSL 셰이더를 로드하고,setFloat()와setImageSampler()로 유니폼 값을 전달한다. - 실무 기준: 셰이더 컴파일은 비용이 크므로 애니메이션 시작 전에 미리 로드(precache)해야 한다.
- 한계: UBO, SSBO, 커스텀 varying은 지원하지 않으며,
sampler2D만 사용 가능하다.
문서가 설명하는 범위
- GLSL 셰이더 파일 작성과 프로젝트 등록
- FragmentProgram과 FragmentShader 사용법
- 유니폼 변수와 텍스처 전달
- CustomPainter와 셰이더 통합
읽는 시간: 15분 | 난이도: 고급
참고 자료
- Fragment shaders - Flutter 공식 셰이더 가이드
- The Book of Shaders - 셰이더 학습 리소스
- flutter_shaders - 셰이더 유틸리티 패키지
문제 상황
앱에서 고급 시각 효과를 구현할 때 CPU만으로는 한계가 있습니다.
GPU 가속이 필요한 상황
문제 1: 픽셀 단위 그라데이션, 노이즈 → CPU 처리 시 프레임 드롭문제 2: 실시간 블러, 왜곡 효과 → 매 프레임 이미지 처리 비용이 높음문제 3: 물결, 파동 같은 수학적 효과 → 수백만 픽셀 계산 필요문제 4: 게임이나 인터랙티브 그래픽 → 60fps 유지 어려움해결 방법
Flutter는 GLSL Fragment Shader를 지원합니다. GPU에서 픽셀 단위 연산을 병렬로 처리해서 고성능 그래픽 효과를 구현할 수 있습니다.
챕터 1: 셰이더 파일 생성과 등록
Why
NOTE셰이더를 사용하려면 GLSL 파일을 작성하고 Flutter 프로젝트에 등록해야 합니다.
빌드 시 셰이더가 컴파일되어 앱 번들에 포함됩니다..frag 파일 작성 → pubspec.yaml 등록 → 빌드 시 컴파일 → 런타임 로드
What
NOTEFlutter는 GLSL ES 1.0 호환 문법을 사용하며,
#version 460 core로 시작합니다.
Skia와 Impeller 백엔드 모두 지원합니다.
How
TIP프로젝트 구조
my_app/├── shaders/│ ├── gradient.frag│ └── ripple.frag├── lib/│ └── main.dart└── pubspec.yamlpubspec.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});@overrideWidget 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 백엔드에서 비용이 큽니다.
애니메이션 중에 처음 로드하면 프레임 드롭이 발생합니다.// 잘못된 방법 - 애니메이션 중 로드@overridevoid paint(Canvas canvas, Size size) async {// 매 프레임 로드 시도 → 성능 저하final program = await FragmentProgram.fromAsset('shaders/effect.frag');}// 올바른 방법 - 미리 로드class MyPainter extends CustomPainter {final FragmentShader shader; // 미리 로드된 셰이더MyPainter(this.shader);@overridevoid 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, 1uniform float uTime; // 인덱스 2uniform vec4 uColor; // 인덱스 3, 4, 5, 6out 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 타입 인덱스 수 예시 float1 uniform float a;→ 인덱스 0vec22 uniform vec2 b;→ 인덱스 1, 2vec33 uniform vec3 c;→ 인덱스 3, 4, 5vec44 uniform vec4 d;→ 인덱스 6, 7, 8, 9
Watch out
WARNING색상은 premultiplied alpha 형식으로 전달해야 합니다.
Flutter의 Color는 straight alpha를 사용하므로 변환이 필요합니다.// 잘못된 방법 - straight alphashader.setFloat(0, color.red / 255); // Rshader.setFloat(1, color.green / 255); // Gshader.setFloat(2, color.blue / 255); // Bshader.setFloat(3, color.opacity); // A// 올바른 방법 - premultiplied alphashader.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
CustomPainter의paint()메서드에서 셰이더를 적용한 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);@overridevoid 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,);}@overridebool shouldRepaint(ShaderPainter oldDelegate) {return time != oldDelegate.time;}}애니메이션과 결합
class AnimatedShaderWidget extends StatefulWidget {final ui.FragmentProgram program;const AnimatedShaderWidget({super.key, required this.program});@overrideState<AnimatedShaderWidget> createState() => _AnimatedShaderWidgetState();}class _AnimatedShaderWidgetState extends State<AnimatedShaderWidget>with SingleTickerProviderStateMixin {late AnimationController _controller;late ui.FragmentShader _shader;@overridevoid initState() {super.initState();_shader = widget.program.fragmentShader();_controller = AnimationController(vsync: this,duration: const Duration(seconds: 10),)..repeat();}@overridevoid dispose() {_controller.dispose();super.dispose();}@overrideWidget 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});@overrideWidget 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인스턴스를 매 프레임 새로 생성하면 성능이 저하됩니다.
인스턴스를 재사용하고 유니폼만 업데이트하세요.// 잘못된 방법 - 매 프레임 새 인스턴스@overridevoid paint(Canvas canvas, Size size) {final shader = _program.fragmentShader(); // 매번 생성shader.setFloat(0, time);canvas.drawRect(...);}// 올바른 방법 - 인스턴스 재사용final _shader = _program.fragmentShader(); // 한 번만 생성@overridevoid 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_OPENGLESuv.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);@overridevoid 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,);}@overridebool 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});@overrideState<TextureShaderWidget> createState() => _TextureShaderWidgetState();}class _TextureShaderWidgetState extends State<TextureShaderWidget>with SingleTickerProviderStateMixin {ui.FragmentProgram? _program;ui.Image? _image;late AnimationController _controller;@overridevoid 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;});}@overrideWidget 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, 1uniform float uTime; // float 인덱스 2uniform sampler2D uTex1; // sampler 인덱스 0uniform sampler2D uTex2; // sampler 인덱스 1// Dartshader.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 ~ 1uniform float uContrast; // 0 ~ 2uniform float uSaturation; // 0 ~ 2out vec4 fragColor;void main() {vec2 uv = FlutterFragCoord().xy / uSize;#ifdef IMPELLER_TARGET_OPENGLESuv.y = 1.0 - uv.y;#endifvec4 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
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!