Вы уже умеете работать с графикой и создавать простые визуальные эффекты с помощью 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.
Вот небольшой пример — текст, раскрашенный с использованием шейдера линейного градиента.
1class 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.
