3.11. CustomPainter: шейдеры

Автор: Дмитрий Маркин

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

Вот небольшой пример — текст, раскрашенный с использованием шейдера линейного градиента.

class GradientShaderPainter extends CustomPainter {
  const GradientShaderPainter();

  @override
  void paint(Canvas canvas, Size size) {
    // Координата Y середины холста
    final verticalCenter = size.height / 2;
    // Смещение
    final offset = Offset(0, verticalCenter);

    // Шейдер линейного градиента
    final shader = const LinearGradient(
      colors: [Colors.red, Colors.blue],
    ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
  
  // Выделяем слой для отрисовки текста
    canvas.saveLayer(null, Paint());

    TextPainter(
      text: const TextSpan(
        text: 'Текст написан с использованием шейдеров',
        style: TextStyle(color: Colors.black),
      ),
      textDirection: TextDirection.ltr,
      textAlign: TextAlign.center,
    )
      // Расчёт размеров, который занимает текст
      ..layout(minWidth: size.width, maxWidth: size.width)
      // Отрисовка текста на холсте
      ..paint(canvas, offset);

  // Выделяем слой для градиента
    canvas.saveLayer(null, Paint()..blendMode = BlendMode.srcATop);
  // Рисуем на холсте Paint с градиентом
    canvas.drawPaint(Paint()..shader = shader);
  // Объединяем градиент с текстом
    canvas.restore();
  
  // Объединяем текст с исходным фоном
    canvas.restore();
  }

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

Group

BlendMode.srcATop — накладывает изображение (source) только на пересечение с фоном (destination). В данном случае source — это градиент, а destination — текст.

Собственные шейдеры

Во Flutter есть возможность загрузить свои собственные шейдеры, для этого необходимо выполнить следующие шаги:

  1. Поместить в проект файл шейдера в формате glsl (напомним, GLSL — язык для программирования шейдеров). Файл имеет расширение .frag.
  2. Добавить по аналогии с другими ассетами в pubspec.yaml секцию shaders и указать путь до файла шейдера.
  3. Создать объект FragmentProgram через именованный конструктор fromAsset, передав путь к файлу шейдера.
  4. Вызывать метод fragmentShader, у ранее созданного объекта FragmentProgram.

Давайте же приступим. Для начала создаём файл custom_shader.frag и добавляем его в pubspec.yaml в секцию shaders. Содержимое файла покажем чуть ниже.

flutter:
  shaders:
    - assets/shaders/custom_shader.frag

Загрузка шейдера — асинхронный процесс, поэтому необходимо предварительно загрузить шейдер перед передачей его в CustomPainter.

Future<FragmentShader> _loadShader(String shaderAssetPath) async {
  final program = await FragmentProgram.fromAsset(shaderAssetPath);

  return program.fragmentShader();
}

Теперь можно создать CustomPainter и передать туда шейдер.

class ShaderPainter extends CustomPainter {
  final FragmentShader shader;

  ShaderPainter({required this.shader});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..shader = shader;
    canvas.drawRect(
      Rect.fromLTWH(0, 0, size.width, size.height),
      paint,
    );
  }

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

На стороне Flutter — всё готово чтобы отрисовывать шейдер, но пока наш файл шейдера пуст. Исправим это! Для начала просто просто нарисуем синий фон.

// Версия API
#version 460 core

// Вспомогательный инструментарий для Flutter
#include <flutter/runtime_effect.glsl>

// Точность вычислений GPU
precision mediump float;

// Итоговый цвет пикселя
out vec4 fragColor;

// Константа - синий цвет
const vec3 blue = vec3(0, 0, 255) / 255;

void main() {
  fragColor = vec4(blue, 1);
}

Group

Метод main выполняется для каждого пикселя, рассчитывая финальный цвет пикселя (fragColor). Переделаем наш шейдер так чтобы получать цвет в качестве входной переменной. Для этого необходимо объявить переменную с ключевым словом uniform — оно означает, что переменная будет передана снаружи.

// Версия API
#version 460 core

// Вспомогательный инстурментарий для Flutter
#include <flutter/runtime_effect.glsl>

// Точность вычислений GPU
precision mediump float;

// Итоговый цвет пикселя
out vec4 fragColor;

// Переменные, передаваемые в шейдер
uniform vec3 color; // Цвет

void main() {
  fragColor = vec4(color/255, 1);
}

Напомним, что каждый канал пикселя — это значение от 0 до 254. Но в синтаксисе GLSL мы оперируем векторами, и значения вектора цвета должно быть в пределах от 0 до 1. Поэтому делим вектор color на 255.

Доработаем CustomPainter и передадим цвет в шейдер.

...
  static const color = Colors.red;

  @override
  void paint(Canvas canvas, Size size) {
    shader
      ..setFloat(0, color.red.toDouble())
      ..setFloat(1, color.green.toDouble())
      ..setFloat(2, color.blue.toDouble());

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

Group

API шейдера очень низкоуровневое. Мы должны передать каждый параметр отдельно — вызвать метод setFloat и передать индекс аргумента и его значение.

В данном примере есть только одна входная переменная с типом vec3. vec3 — это просто последовательность из трёх переменных, поэтому мы и передаём значения, указывая индекс в диапазоне от 0 до 2. Представим что есть ещё переменная с типом vec2 , тогда чтобы передать параметры в неё, необходимо вызвать метод setFloat ещё два раза с индексами 3 и 4.

Сейчас каждый пиксель краситься в один и тот же цвет. Добавим в шейдер логику, которая будет менять яркость цвета в зависимости от Y-координаты пикселя.

// Версия API
#version 460 core

// Вспомогательный инстурментарий для Flutter
#include <flutter/runtime_effect.glsl>

// Точность вычислений GPU
precision mediump float;

// Итоговый цвет пикселя
out vec4 fragColor;

// Переменные, передаваемые в шейдер
uniform vec3 color; // Цвет
uniform vec2 resolution; // Разрешение экрана

void main() {
  // Коордианты пикселя
  vec2 pixelCoord = FlutterFragCoord();

  // Позиция пикселя относительно разрешения экрана
  vec2 positionOnScreen = pixelCoord / resolution;
   
  fragColor = vec4((color/255) * positionOnScreen.y, 1);
}

Теперь передадим размеры экрана в CustomPainter и посмотрим что будет выведено на экране.

...
 @override
  void paint(Canvas canvas, Size size) {
    shader
      ..setFloat(0, color.red.toDouble())
      ..setFloat(1, color.green.toDouble())
      ..setFloat(2, color.blue.toDouble())
      ..setFloat(3, size.width)
      ..setFloat(4, size.height);

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

Group

Таким образом мы получили шейдер, который динамически рассчитывает яркость цвета в зависимости от Y-координаты, т.е. простейший градиент.

Конечно, вряд ли вы будете писать более сложные шейдеры сами. Для поиска подходящего шейдера можно воспользоваться ресурсом GLSL SANDBOX — это приложение WebGL для live-кодирования шейдеров, которая также содержит большую библиотеку шейдеров, созданных пользователями ресурса.

Но есть одна проблема: скорее всего, без некоторой адаптации эти виджеты не будут компилироваться во Flutter-приложении. Это связано с особенностями реализации поддержки шейдеров во Flutter.

Поэтому давайте возьмём какой-нибудь шейдер и попробуем его адаптировать, например вот этот. Скопируем код в файл и добавим его в проект.

#ifdef GL_ES
precision mediump float;
#endif

uniform float time;
uniform vec2 mouse;
uniform vec2 resolution;

const int MAXITER = 30;

vec3 field(vec3 p) {
 p *= .1;
 float f = .1;
 for (int i = 0; i < 3; i++) {
  p = p.yzx; //*mat3(.8,.6,0,-.6,.8,0,0,0,1);
//  p += vec3(.123,.456,.789)*float(i);
  p = abs(fract(p)-.5);
  p *= 2.0;
  f *= 2.0;
 }
 p *= p;
 return sqrt(p+p.yzx)/f-.05;
}

void main( void ) {
 vec3 dir = normalize(vec3((gl_FragCoord.xy-resolution*.5)/resolution.x,1.));
 float a = time * 0.1;
 vec3 pos = vec3(0.0,time*0.1,0.0);
 dir *= mat3(1,0,0,0,cos(a),-sin(a),0,sin(a),cos(a));
 dir *= mat3(cos(a),0,-sin(a),0,1,0,sin(a),0,cos(a));
 vec3 color = vec3(0);
 for (int i = 0; i < MAXITER; i++) {
  vec3 f2 = field(pos);
  float f = min(min(f2.x,f2.y),f2.z);
  
  pos += dir*f;
  color += float(MAXITER-i)/(f2+.01);
 }
 vec3 color3 = vec3(1.-1./(1.+color*(.09/float(MAXITER*MAXITER))));
 color3 *= color3;
 gl_FragColor = vec4(vec3(color3.r+color3.g+color3.b),1.);
}

Попытавшись запустить проект мы получим ошибку ещё во время компиляции. Давайте попробуем поправить код, ориентируясь на пример, который мы сделали ранее.

Во-первых, меняем конфигурационные строки:

~~#ifdef GL_ES
precision mediump float;
#endif~~

// Версия API
#version 460 core

// Вспомогательный инстурментарий для Flutter
#include <flutter/runtime_effect.glsl>

// Точность вычислений GPU
precision mediump float;

...

Всё равно получаем ошибку компиляции. В коде находим gl_FragColor и gl_FragCoord, нам нужно заменить их на fragColor и FlutterFragCoord() используемые ранее.

// Версия API
#version 460 core

// Вспомогательный инстурментарий для Flutter
#include <flutter/runtime_effect.glsl>

// Точность вычислений GPU
precision mediump float;

// Итоговый цвет пикселя
out vec4 fragColor;

uniform float time;
uniform vec2 mouse;
uniform vec2 resolution;

const int MAXITER = 30;

vec3 field(vec3 p) {
 p *= .1;
 float f = .1;
 for (int i = 0; i < 3; i++) {
  p = p.yzx; //*mat3(.8,.6,0,-.6,.8,0,0,0,1);
//  p += vec3(.123,.456,.789)*float(i);
  p = abs(fract(p)-.5);
  p *= 2.0;
  f *= 2.0;
 }
 p *= p;
 return sqrt(p+p.yzx)/f-.05;
}

void main() {
 vec3 dir = normalize(vec3((FlutterFragCoord().xy-resolution*.5)/resolution.x,1.));
 float a = time * 0.1;
 vec3 pos = vec3(0.0,time*0.1,0.0);
 dir *= mat3(1,0,0,0,cos(a),-sin(a),0,sin(a),cos(a));
 dir *= mat3(cos(a),0,-sin(a),0,1,0,sin(a),0,cos(a));
 vec3 color = vec3(0);
 for (int i = 0; i < MAXITER; i++) {
  vec3 f2 = field(pos);
  float f = min(min(f2.x,f2.y),f2.z);
  
  pos += dir*f;
  color += float(MAXITER-i)/(f2+.01);
 }
 vec3 color3 = vec3(1.-1./(1.+color*(.09/float(MAXITER*MAXITER))));
 color3 *= color3;
 fragColor = vec4(vec3(color3.r+color3.g+color3.b),1.);
}

Запускаем ещё раз. Ошибка компиляции пропала, но на экране ничего не отобразилось, также в логах ошибки о несовпадении количества аргументов шейдера с переданными нами. Если посмотреть в код шейдера, то мы видим что ожидается передача 3 аргументов:

  • float time — время, можно воспринимать этот параметр как прогресс анимации.
  • vec2 mouse — текущая позиция курсора мыши, от этого параметра мы избавимся, нам он не нужен.
  • vec2 resolution — разрешение экрана.

Итоговая версия нашего шейдера будет выглядеть так:

// Версия API
#version 460 core

// Вспомогательный инстурментарий для Flutter
#include <flutter/runtime_effect.glsl>

// Точность вычислений GPU
precision mediump float;

// Итоговый цвет пикселя
out vec4 fragColor;

uniform float time;
uniform vec2 resolution;

const int MAXITER = 30;

vec3 field(vec3 p) {
 p *= .1;
 float f = .1;
 for (int i = 0; i < 3; i++) {
  p = p.yzx;
  p = abs(fract(p)-.5);
  p *= 2.0;
  f *= 2.0;
 }
 p *= p;
 return sqrt(p+p.yzx)/f-.05;
}

void main() {
 vec3 dir = normalize(vec3((FlutterFragCoord().xy-resolution*.5)/resolution.x,1.));
 float a = time * 0.1;
 vec3 pos = vec3(0.0,time*0.1,0.0);
 dir *= mat3(1,0,0,0,cos(a),-sin(a),0,sin(a),cos(a));
 dir *= mat3(cos(a),0,-sin(a),0,1,0,sin(a),0,cos(a));
 vec3 color = vec3(0);
 for (int i = 0; i < MAXITER; i++) {
  vec3 f2 = field(pos);
  float f = min(min(f2.x,f2.y),f2.z);
  
  pos += dir*f;
  color += float(MAXITER-i)/(f2+.01);
 }
 vec3 color3 = vec3(1.-1./(1.+color*(.09/float(MAXITER*MAXITER))));
 color3 *= color3;
 fragColor = vec4(vec3(color3.r+color3.g+color3.b),1.);
}

Поправим код CustomPainter.

class ShaderPainter extends CustomPainter {
  final FragmentShader shader;

  ShaderPainter({ required this.shader });

  @override
  void paint(Canvas canvas, Size size) {
    shader
      ..setFloat(0, 0)
      ..setFloat(1, size.width)
      ..setFloat(2, size.height);

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

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

Вуаля, на экране отобразилась следующая картинка.

Group

Как видно в коде выше, первым аргументом мы передаём константное значение. Чтобы оживить наш шейдер, добавим анимацию и передадим её значение в этот параметр.

class ShaderPainter extends CustomPainter {
  final Animation<double> animation;
  final FragmentShader shader;

  ShaderPainter({
    required this.shader,
    required this.animation,
  }) : super(repaint: animation);

  @override
  void paint(Canvas canvas, Size size) {
    shader
      ..setFloat(0, animation.value)
      ..setFloat(1, size.width)
      ..setFloat(2, size.height);

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

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

В итоге получаем на экране следующую картину.

Анимация примера 1

Вот и всё! Теперь мы как следует рассмотрели CustomPainter: научились не просто рисовать домики, но и работать со слоями, выполнять трансформации, создавать собственные шейдеры и контролировать смешение цветов с помощью BlendMode.

Если вам интересно углубиться в это тему ещё сильнее, то вот интересные ссылки:

А в следующих статьях мы детальнее изучим процесс визуализации во Flutter — рендер-дерево, а также его важнейший элемент — RenderObject.