Вы уже умеете работать с графикой и создавать простые визуальные эффекты с помощью Canvas
и слоёв
.
В этом параграфе мы сфокусируемся на продвинутых визуальных эффектах, которые можно создать с помощью шейдеров.
Для начала разберёмся, что такое шейдеры и какие шейдеры бывают во Flutter. А затем пошагово создадим и анимкруем свой шейдер.
Что такое шейдер
Шейдеры — это программы, написанные на особом языке (GLSL или HLSL).
Они определяют алгоритмы обработки изображений для разных стадий 3D-графики и используются видеокартами для расчёта окончательного изображения, которое видит пользователь.
Класс Paint
так же позволяет рисовать шейдеры. Для этого нужно передать в параметр shader
наследника класс Shader
.
Виды шейдеров во Flutter
Flutter позволяет использовать как встроенные шейдеры, так и создавать свои.
Рассмотрим оба сценария подробнее.
Встроенные шейдеры
Во Flutter уже реализован некоторый набор шейдеров: LinearGradient
, RadialGradient
, SweepGradient
, ImageShader
.
Стоит уточнить, что классы LinearGradient
, RadialGradient
, SweepGradient
наследуемые от Gradient
(*package:flutter/src/painting/gradient.dart*
) — это не шейдеры.
Чтобы создать непосредственно шейдер, необходимо вызвать метод createShader
, который создаёт объект Gradient
из библиотеки *dart:ui*
— и вот уже он является наследником класса Shader
.
Вот небольшой пример — текст, раскрашенный с использованием шейдера линейного градиента.
1 GradientShaderPainter extends CustomPainter {
2 const GradientShaderPainter();
3
4 @override
5 void paint(Canvas canvas, Size size) {
6 // Координата Y середины холста
7 final verticalCenter = size.height / 2;
8 // Смещение
9 final offset = Offset(0, verticalCenter);
10
11 // Шейдер линейного градиента
12 final shader = const LinearGradient(
13 colors: [Colors.red, Colors.blue],
14 ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
15
16 // Выделяем слой для отрисовки текста
17 canvas.saveLayer(null, Paint());
18
19 TextPainter(
20 text: const TextSpan(
21 text: 'Текст написан с использованием шейдеров',
22 style: TextStyle(color: Colors.black),
23 ),
24 textDirection: TextDirection.ltr,
25 textAlign: TextAlign.center,
26 )
27 // Расчёт размеров, который занимает текст
28 ..layout(minWidth: size.width, maxWidth: size.width)
29 // Отрисовка текста на холсте
30 ..paint(canvas, offset);
31
32 // Выделяем слой для градиента
33 canvas.saveLayer(null, Paint()..blendMode = BlendMode.srcATop);
34 // Рисуем на холсте Paint с градиентом
35 canvas.drawPaint(Paint()..shader = shader);
36 // Объединяем градиент с текстом
37 canvas.restore();
38
39 // Объединяем текст с исходным фоном
40 canvas.restore();
41 }
42
43 @override
44 bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
45}
BlendMode.srcATop
— накладывает изображение (source) только на пересечение с фоном (destination). В данном случае source — это градиент, а destination — текст.
Собственные шейдеры
Во Flutter есть возможность загрузить свои собственные шейдеры, для этого необходимо выполнить следующие шаги:
- Поместить в проект файл шейдера в формате glsl (напомним, GLSL — язык для программирования шейдеров). Файл имеет расширение .frag.
- Добавить по аналогии с другими ассетами в pubspec.yaml секцию shaders и указать путь до файла шейдера.
- Создать объект
FragmentProgram
через именованный конструкторfromAsset
, передав путь к файлу шейдера. - Вызывать метод
fragmentShader
, у ранее созданного объектаFragmentProgram
.
Давайте же приступим. Для начала создаём файл custom_shader.frag и добавляем его в pubspec.yaml в секцию shaders. Содержимое файла покажем чуть ниже.
1flutter:
2 shaders:
3 - assets/shaders/custom_shader.frag
Загрузка шейдера — асинхронный процесс, поэтому необходимо предварительно загрузить шейдер перед передачей его в CustomPainter
.
1Future<FragmentShader> _loadShader(String shaderAssetPath) async {
2 final program = await FragmentProgram.fromAsset(shaderAssetPath);
3
4 return program.fragmentShader();
5}
Теперь можно создать CustomPainter
и передать туда шейдер.
1class ShaderPainter extends CustomPainter {
2 final FragmentShader shader;
3
4 ShaderPainter({required this.shader});
5
6 @override
7 void paint(Canvas canvas, Size size) {
8 final paint = Paint()..shader = shader;
9 canvas.drawRect(
10 Rect.fromLTWH(0, 0, size.width, size.height),
11 paint,
12 );
13 }
14
15 @override
16 bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
17}
На стороне Flutter — всё готово чтобы отрисовывать шейдер, но пока наш файл шейдера пуст. Исправим это! Для начала просто просто нарисуем синий фон.
1// Версия API
2#version 460 core
3
4// Вспомогательный инструментарий для Flutter
5#include <flutter/runtime_effect.glsl>
6
7// Точность вычислений GPU
8precision mediump float;
9
10// Итоговый цвет пикселя
11out vec4 fragColor;
12
13// Константа - синий цвет
14const vec3 blue = vec3(0, 0, 255) / 255;
15
16void main() {
17 fragColor = vec4(blue, 1);
18}
Метод main
выполняется для каждого пикселя, рассчитывая финальный цвет пикселя (fragColor
). Переделаем наш шейдер так чтобы получать цвет в качестве входной переменной. Для этого необходимо объявить переменную с ключевым словом uniform
— оно означает, что переменная будет передана снаружи.
1// Версия API
2#version 460 core
3
4// Вспомогательный инстурментарий для Flutter
5#include <flutter/runtime_effect.glsl>
6
7// Точность вычислений GPU
8precision mediump float;
9
10// Итоговый цвет пикселя
11out vec4 fragColor;
12
13// Переменные, передаваемые в шейдер
14uniform vec3 color; // Цвет
15
16void main() {
17 fragColor = vec4(color/255, 1);
18}
Напомним, что каждый канал пикселя — это значение от 0 до 254. Но в синтаксисе GLSL мы оперируем векторами, и значения вектора цвета должно быть в пределах от 0 до 1. Поэтому делим вектор color
на 255.
Доработаем CustomPainter
и передадим цвет в шейдер.
1...
2 static const color = Colors.red;
3
4 @override
5 void paint(Canvas canvas, Size size) {
6 shader
7 ..setFloat(0, color.red.toDouble())
8 ..setFloat(1, color.green.toDouble())
9 ..setFloat(2, color.blue.toDouble());
10
11 final paint = Paint()..shader = shader;
12 canvas.drawRect(
13 Rect.fromLTWH(0, 0, size.width, size.height),
14 paint,
15 );
16 }
17...
API шейдера очень низкоуровневое. Мы должны передать каждый параметр отдельно — вызвать метод setFloat
и передать индекс аргумента и его значение.
В данном примере есть только одна входная переменная с типом vec3
. vec3
— это просто последовательность из трёх переменных, поэтому мы и передаём значения, указывая индекс в диапазоне от 0 до 2. Представим что есть ещё переменная с типом vec2
, тогда чтобы передать параметры в неё, необходимо вызвать метод setFloat
ещё два раза с индексами 3 и 4.
Сейчас каждый пиксель краситься в один и тот же цвет. Добавим в шейдер логику, которая будет менять яркость цвета в зависимости от Y-координаты пикселя.
1// Версия API
2#version 460 core
3
4// Вспомогательный инстурментарий для Flutter
5#include <flutter/runtime_effect.glsl>
6
7// Точность вычислений GPU
8precision mediump float;
9
10// Итоговый цвет пикселя
11out vec4 fragColor;
12
13// Переменные, передаваемые в шейдер
14uniform vec3 color; // Цвет
15uniform vec2 resolution; // Разрешение экрана
16
17void main() {
18 // Коордианты пикселя
19 vec2 pixelCoord = FlutterFragCoord();
20
21 // Позиция пикселя относительно разрешения экрана
22 vec2 positionOnScreen = pixelCoord / resolution;
23
24 fragColor = vec4((color/255) * positionOnScreen.y, 1);
25}
Теперь передадим размеры экрана в CustomPainter
и посмотрим что будет выведено на экране.
1...
2 @override
3 void paint(Canvas canvas, Size size) {
4 shader
5 ..setFloat(0, color.red.toDouble())
6 ..setFloat(1, color.green.toDouble())
7 ..setFloat(2, color.blue.toDouble())
8 ..setFloat(3, size.width)
9 ..setFloat(4, size.height);
10
11 final paint = Paint()..shader = shader;
12 canvas.drawRect(
13 Rect.fromLTWH(0, 0, size.width, size.height),
14 paint,
15 );
16 }
17...
Таким образом мы получили шейдер, который динамически рассчитывает яркость цвета в зависимости от Y-координаты, т.е. простейший градиент.
Конечно, вряд ли вы будете писать более сложные шейдеры сами. Для поиска подходящего шейдера можно воспользоваться ресурсом GLSL SANDBOX — это приложение WebGL для live-кодирования шейдеров, которая также содержит большую библиотеку шейдеров, созданных пользователями ресурса.
Но есть одна проблема: скорее всего, без некоторой адаптации эти виджеты не будут компилироваться во Flutter-приложении. Это связано с особенностями реализации поддержки шейдеров во Flutter.
Поэтому давайте возьмём какой-нибудь шейдер и попробуем его адаптировать, например вот этот. Скопируем код в файл и добавим его в проект.
1#ifdef GL_ES
2precision mediump float;
3#endif
4
5uniform float time;
6uniform vec2 mouse;
7uniform vec2 resolution;
8
9const int MAXITER = 30;
10
11vec3 field(vec3 p) {
12 p *= .1;
13 float f = .1;
14 for (int i = 0; i < 3; i++) {
15 p = p.yzx; //*mat3(.8,.6,0,-.6,.8,0,0,0,1);
16// p += vec3(.123,.456,.789)*float(i);
17 p = abs(fract(p)-.5);
18 p *= 2.0;
19 f *= 2.0;
20 }
21 p *= p;
22 return sqrt(p+p.yzx)/f-.05;
23}
24
25void main( void ) {
26 vec3 dir = normalize(vec3((gl_FragCoord.xy-resolution*.5)/resolution.x,1.));
27 float a = time * 0.1;
28 vec3 pos = vec3(0.0,time*0.1,0.0);
29 dir *= mat3(1,0,0,0,cos(a),-sin(a),0,sin(a),cos(a));
30 dir *= mat3(cos(a),0,-sin(a),0,1,0,sin(a),0,cos(a));
31 vec3 color = vec3(0);
32 for (int i = 0; i < MAXITER; i++) {
33 vec3 f2 = field(pos);
34 float f = min(min(f2.x,f2.y),f2.z);
35
36 pos += dir*f;
37 color += float(MAXITER-i)/(f2+.01);
38 }
39 vec3 color3 = vec3(1.-1./(1.+color*(.09/float(MAXITER*MAXITER))));
40 color3 *= color3;
41 gl_FragColor = vec4(vec3(color3.r+color3.g+color3.b),1.);
42}
Попытавшись запустить проект мы получим ошибку ещё во время компиляции. Давайте попробуем поправить код, ориентируясь на пример, который мы сделали ранее.
Во-первых, меняем конфигурационные строки:
1~~#ifdef GL_ES
2precision mediump float;
3#endif~~
4
5// Версия API
6#version 460 core
7
8// Вспомогательный инстурментарий для Flutter
9#include <flutter/runtime_effect.glsl>
10
11// Точность вычислений GPU
12precision mediump float;
13
14...
Всё равно получаем ошибку компиляции. В коде находим gl_FragColor
и gl_FragCoord
, нам нужно заменить их на fragColor
и FlutterFragCoord()
используемые ранее.
1// Версия API
2#version 460 core
3
4// Вспомогательный инстурментарий для Flutter
5#include <flutter/runtime_effect.glsl>
6
7// Точность вычислений GPU
8precision mediump float;
9
10// Итоговый цвет пикселя
11out vec4 fragColor;
12
13uniform float time;
14uniform vec2 mouse;
15uniform vec2 resolution;
16
17const int MAXITER = 30;
18
19vec3 field(vec3 p) {
20 p *= .1;
21 float f = .1;
22 for (int i = 0; i < 3; i++) {
23 p = p.yzx; //*mat3(.8,.6,0,-.6,.8,0,0,0,1);
24// p += vec3(.123,.456,.789)*float(i);
25 p = abs(fract(p)-.5);
26 p *= 2.0;
27 f *= 2.0;
28 }
29 p *= p;
30 return sqrt(p+p.yzx)/f-.05;
31}
32
33void main() {
34 vec3 dir = normalize(vec3((FlutterFragCoord().xy-resolution*.5)/resolution.x,1.));
35 float a = time * 0.1;
36 vec3 pos = vec3(0.0,time*0.1,0.0);
37 dir *= mat3(1,0,0,0,cos(a),-sin(a),0,sin(a),cos(a));
38 dir *= mat3(cos(a),0,-sin(a),0,1,0,sin(a),0,cos(a));
39 vec3 color = vec3(0);
40 for (int i = 0; i < MAXITER; i++) {
41 vec3 f2 = field(pos);
42 float f = min(min(f2.x,f2.y),f2.z);
43
44 pos += dir*f;
45 color += float(MAXITER-i)/(f2+.01);
46 }
47 vec3 color3 = vec3(1.-1./(1.+color*(.09/float(MAXITER*MAXITER))));
48 color3 *= color3;
49 fragColor = vec4(vec3(color3.r+color3.g+color3.b),1.);
50}
Запускаем ещё раз. Ошибка компиляции пропала, но на экране ничего не отобразилось, также в логах ошибки о несовпадении количества аргументов шейдера с переданными нами. Если посмотреть в код шейдера, то мы видим что ожидается передача 3 аргументов:
float time
— время, можно воспринимать этот параметр как прогресс анимации.vec2 mouse
— текущая позиция курсора мыши, от этого параметра мы избавимся, нам он не нужен.vec2 resolution
— разрешение экрана.
Итоговая версия нашего шейдера будет выглядеть так:
1// Версия API
2#version 460 core
3
4// Вспомогательный инстурментарий для Flutter
5#include <flutter/runtime_effect.glsl>
6
7// Точность вычислений GPU
8precision mediump float;
9
10// Итоговый цвет пикселя
11out vec4 fragColor;
12
13uniform float time;
14uniform vec2 resolution;
15
16const int MAXITER = 30;
17
18vec3 field(vec3 p) {
19 p *= .1;
20 float f = .1;
21 for (int i = 0; i < 3; i++) {
22 p = p.yzx;
23 p = abs(fract(p)-.5);
24 p *= 2.0;
25 f *= 2.0;
26 }
27 p *= p;
28 return sqrt(p+p.yzx)/f-.05;
29}
30
31void main() {
32 vec3 dir = normalize(vec3((FlutterFragCoord().xy-resolution*.5)/resolution.x,1.));
33 float a = time * 0.1;
34 vec3 pos = vec3(0.0,time*0.1,0.0);
35 dir *= mat3(1,0,0,0,cos(a),-sin(a),0,sin(a),cos(a));
36 dir *= mat3(cos(a),0,-sin(a),0,1,0,sin(a),0,cos(a));
37 vec3 color = vec3(0);
38 for (int i = 0; i < MAXITER; i++) {
39 vec3 f2 = field(pos);
40 float f = min(min(f2.x,f2.y),f2.z);
41
42 pos += dir*f;
43 color += float(MAXITER-i)/(f2+.01);
44 }
45 vec3 color3 = vec3(1.-1./(1.+color*(.09/float(MAXITER*MAXITER))));
46 color3 *= color3;
47 fragColor = vec4(vec3(color3.r+color3.g+color3.b),1.);
48}
Поправим код CustomPainter
.
1class ShaderPainter extends CustomPainter {
2 final FragmentShader shader;
3
4 ShaderPainter({ required this.shader });
5
6 @override
7 void paint(Canvas canvas, Size size) {
8 shader
9 ..setFloat(0, 0)
10 ..setFloat(1, size.width)
11 ..setFloat(2, size.height);
12
13 final paint = Paint()..shader = shader;
14 canvas.drawRect(
15 Rect.fromLTWH(0, 0, size.width, size.height),
16 paint,
17 );
18 }
19
20 @override
21 bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
22}
Вуаля, на экране отобразилась следующая картинка.
Как видно в коде выше, первым аргументом мы передаём константное значение. Чтобы оживить наш шейдер, добавим анимацию и передадим её значение в этот параметр.
1class ShaderPainter extends CustomPainter {
2 final Animation<double> animation;
3 final FragmentShader shader;
4
5 ShaderPainter({
6 required this.shader,
7 required this.animation,
8 }) : super(repaint: animation);
9
10 @override
11 void paint(Canvas canvas, Size size) {
12 shader
13 ..setFloat(0, animation.value)
14 ..setFloat(1, size.width)
15 ..setFloat(2, size.height);
16
17 final paint = Paint()..shader = shader;
18 canvas.drawRect(
19 Rect.fromLTWH(0, 0, size.width, size.height),
20 paint,
21 );
22 }
23
24 @override
25 bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
26}
В итоге получаем на экране следующую картину.
Вот и всё! Теперь мы как следует рассмотрели CustomPainter
: научились не просто рисовать домики, но и работать со слоями, выполнять трансформации, создавать собственные шейдеры и контролировать смешение цветов с помощью BlendMode
.
Если вам интересно углубиться в это тему ещё сильнее, то вот интересные ссылки:
- Официальная документация по CustomPainter.
- Статья: использование шейдеров во Flutter.
- Видео: использование шейдеров во Flutter.
- Видео: рисование с использованием вершин.
А в следующих параграфах мы детальнее изучим процесс визуализации во Flutter — рендер-дерево, а также его важнейший элемент — RenderObject
.