Использование шейдеров во Flutter. Часть 2

Всем привет! На связи Юрий Петров, Flutter Team Lead в Friflex. В предыдущей статье мы познакомились с работой шейдеров во Flutter, а также рассмотрели, как написать свой собственный шейдер на языке GLSL. В этой части разберемся, как импортировать готовые шейдеры и управлять ими из Flutter.

598db46fad7ab2466060c71dae95e1dd.gif

Содержание:

Если, вы еще не читали первую часть, рекомендую сначала ознакомиться с ней, а потом перейти ко второй части.

Шейдеры — это маленькие программы, написанные на языке GLSL. Соответственно, существуют ресурсы, где пользователи делятся уже готовыми шейдерами. Один из них —https://glslsandbox.com/. В данном хранилище можно прямо в браузере пробовать изменять код шейдера. Это очень удобно для отладки шейдера. В последнее время на этом ресурсе есть некоторые проблемы с модерацией контента. Но нас интересует очень красивый шейдер https://glslsandbox.com/e#94097.0 — полет в облаках.

Шейдер опубликованный на сайте https://glslsandbox.com/

d08357598585f9d7fa08927033067ebe.png

При просмотре данного шейдера с помощью кнопки Show/Hide Code мы можем показать/скрыть исходный код. Именно этот исходный код нам нужен.

Если такую анимацию попробовать реализовать стандартными средствами Flutter, то это будет очень сложная задача. А вот написать такой шейдер на GLSL не составит большого труда. Если, конечно, у вас есть практика написания шейдеров на GLSL.

Импортирование шейдера

Копируем исходный код со страницы шейдера и вставляем в наш ранее написанный файл shader.glsl.

Исходный код шейдера

#extension GL_OES_standard_derivatives : enable

precision highp float;

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

#define iTime time
#define iMouse mouse
#define iResolution resolution

// referred https://www.shadertoy.com/view/4sXGRM

vec3 skytop = vec3(0.05, 0.2, 0.5);

vec3 light = normalize(vec3(0.1, 0.25, 0.9));

vec2 cloudrange = vec2(0.0, 10000.0);

mat3 m = mat3(0.00, 1.60, 1.20, -1.60, 0.72, -0.96, -1.20, -0.96, 1.28);

// hash function              
float hash(float n)
{
    return fract(cos(n) * 114514.1919);
}

// 3d noise function
float noise(in vec3 x)
{
    vec3 p = floor(x);
    vec3 f = smoothstep(0.0, 1.0, fract(x));
        
    float n = p.x + p.y * 10.0 + p.z * 100.0;
    
    return mix(
        mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),
            mix(hash(n + 10.0), hash(n + 11.0), f.x), f.y),
        mix(mix(hash(n + 100.0), hash(n + 101.0), f.x),
            mix(hash(n + 110.0), hash(n + 111.0), f.x), f.y), f.z);
}

// Fractional Brownian motion
float fbm(vec3 p)
{
    float f = 0.5000 * noise(p);
    p = m * p;
    f += 0.2500 * noise(p);
    p = m * p;
    f += 0.1666 * noise(p);
    p = m * p;
    f += 0.0834 * noise(p);
    return f;
}

vec3 camera(float time)
{
    return vec3(5000.0 * sin(1.0 * time), 5000. + 1500. * sin(0.5 * time), 6000.0 * time);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 uv = 2. * fragCoord.xy / iResolution.xy - 1.0;
    uv.x *= iResolution.x / iResolution.y;

    float time = (iTime + 13.5 + 44.) * 1.0;
    vec3 campos = camera(time);
    vec3 camtar = camera(time + 0.4);

    vec3 front = normalize(camtar - campos);
    vec3 right = normalize(cross(front, vec3(0.0, 1.0, 0.0)));
    vec3 up = normalize(cross(right, front));
    vec3 fragAt = normalize(uv.x * right + uv.y * up + front);
    
    // clouds
    vec4 sum = vec4(0, 0, 0, 0);
    for (float depth = 0.0; depth < 100000.0; depth += 200.0)
    {
        vec3 ray = campos + fragAt * depth;
        if (cloudrange.x < ray.y && ray.y < cloudrange.y)
        {
            float alpha = smoothstep(0.5, 1.0, fbm(ray * 0.00025));
            vec3 localcolor = mix(vec3(1.1, 1.05, 1.0), vec3(0.3, 0.3, 0.2), alpha);
            alpha = (1.0 - sum.a) * alpha;
            sum += vec4(localcolor * alpha, alpha);
        }
    }
    
    float alpha = smoothstep(0.7, 1.0, sum.a);
    sum.rgb /= sum.a + 0.0001;

    float sundot = clamp(dot(fragAt, light), 0.0, 1.0);
    vec3 col = 0.8 * (skytop);
    col += 0.47 * vec3(1.6, 1.4, 1.0) * pow(sundot, 350.0);
    col += 0.4 * vec3(0.8, 0.9, 1.0) * pow(sundot, 2.0);
    
    sum.rgb -= 0.6 * vec3(0.8, 0.75, 0.7) * pow(sundot, 13.0) * alpha;
    
    sum.rgb += 0.2 * vec3(1.3, 1.2, 1.0) * pow(sundot, 5.0) * (1.0 - alpha);

    col = mix(col, sum.rgb, sum.a);

    fragColor = vec4(col, 1.0);
}

void main( void ) {
mainImage(gl_FragColor,gl_FragCoord.xy);
}

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

Это означает, что при компиляции GLSL в SPIRV произошла ошибка. Это означает, что при компиляции GLSL в SPIRV произошла ошибка.

Давайте попробуем это исправить. Для этого произведем несколько манипуляций с кодом.

Изменяем:

void mainImage(out vec4 fragColor, in vec2 fragCoord)

на:

void main()

Таким образом, мы убираем вспомогательную функцию mainImage () и делаем из нее точку входа в шейдер.

  • В строке 64, так как теперь мы не получаем аргумент fragCoord, необходимо использовать gl_FragCoord.

Меняем:

 vec2 uv = 2. * fragCoord.xy / iResolution.xy - 1.0;

на:

vec2 uv = 2. * gl_FragCoord.xy / iResolution.xy - 1.0;
  • Так как мы уже объявили точку входа в шейдер, то весь код, начиная со строки 107, нужно удалить:

void main( void ) {
mainImage(gl_FragColor,gl_FragCoord.xy);
}
  • Далее нам необходимо добавить выходную переменную, которая будет, как вектор из четырех чисел + альфа канал. Эту переменную мы будем возвращать как результат работы шейдера во фреймворк Flutter.

out vec4 fragColor;

Меняем:

 for (float depth = 0.0; depth < 100000.0; depth += 200.0)

на:

 for (float depth = 0.0; depth < 10000.0; depth += 200.0)

В итоге у нас должен получиться вот такой исходный код.

Адаптированный исходный код шейдера

#extension GL_OES_standard_derivatives : enable

precision highp float;

uniform float time;
uniform vec2 resolution;
out vec4 fragColor;

#define iTime time
#define iMouse mouse
#define iResolution resolution

// referred https://www.shadertoy.com/view/4sXGRM

vec3 skytop = vec3(0.05, 0.2, 0.5);

vec3 light = normalize(vec3(0.1, 0.25, 0.9));

vec2 cloudrange = vec2(0.0, 10000.0);

mat3 m = mat3(0.00, 1.60, 1.20, -1.60, 0.72, -0.96, -1.20, -0.96, 1.28);

// hash function
float hash(float n)
{
    return fract(cos(n) * 114514.1919);
}

// 3d noise function
float noise(in vec3 x)
{
    vec3 p = floor(x);
    vec3 f = smoothstep(0.0, 1.0, fract(x));

    float n = p.x + p.y * 10.0 + p.z * 100.0;

    return mix(
        mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),
            mix(hash(n + 10.0), hash(n + 11.0), f.x), f.y),
        mix(mix(hash(n + 100.0), hash(n + 101.0), f.x),
            mix(hash(n + 110.0), hash(n + 111.0), f.x), f.y), f.z);
}

// Fractional Brownian motion
float fbm(vec3 p)
{
    float f = 0.5000 * noise(p);
    p = m * p;
    f += 0.2500 * noise(p);
    p = m * p;
    f += 0.1666 * noise(p);
    p = m * p;
    f += 0.0834 * noise(p);
    return f;
}

vec3 camera(float time)
{
    return vec3(5000.0 * sin(1.0 * time), 5000. + 1500. * sin(0.5 * time), 6000.0 * time);
}

void main()
{
    vec2 uv = 2. * gl_FragCoord.xy / iResolution.xy - 1.0;
    uv.x *= iResolution.x / iResolution.y;

    float time = (iTime + 13.5 + 44.) * 1.0;
    vec3 campos = camera(time);
    vec3 camtar = camera(time + 0.4);

    vec3 front = normalize(camtar - campos);
    vec3 right = normalize(cross(front, vec3(0.0, 1.0, 0.0)));
    vec3 up = normalize(cross(right, front));
    vec3 fragAt = normalize(uv.x * right + uv.y * up + front);

    // clouds
    vec4 sum = vec4(0, 0, 0, 0);
     for (float depth = 0.0; depth < 10000.0; depth += 200.0)
    {
        vec3 ray = campos + fragAt * depth;
        if (cloudrange.x < ray.y && ray.y < cloudrange.y)
        {
            float alpha = smoothstep(0.5, 1.0, fbm(ray * 0.00025));
            vec3 localcolor = mix(vec3(1.1, 1.05, 1.0), vec3(0.3, 0.3, 0.2), alpha);
            alpha = (1.0 - sum.a) * alpha;
            sum += vec4(localcolor * alpha, alpha);
        }
    }

    float alpha = smoothstep(0.7, 1.0, sum.a);
    sum.rgb /= sum.a + 0.0001;

    float sundot = clamp(dot(fragAt, light), 0.0, 1.0);
    vec3 col = 0.8 * (skytop);
    col += 0.47 * vec3(1.6, 1.4, 1.0) * pow(sundot, 350.0);
    col += 0.4 * vec3(0.8, 0.9, 1.0) * pow(sundot, 2.0);

    sum.rgb -= 0.6 * vec3(0.8, 0.75, 0.7) * pow(sundot, 13.0) * alpha;

    sum.rgb += 0.2 * vec3(1.3, 1.2, 1.0) * pow(sundot, 5.0) * (1.0 - alpha);

    col = mix(col, sum.rgb, sum.a);

    fragColor = vec4(col, 1.0);
}

Запускаем проект и любуемся полетом в облаках на вашем смартфоне. А что если вы хотите управлять шейдерами из Flutter?

Результат работы шейдера

Управляем шейдером из Flutter

Для удобного управления шейдером, реализуем следующее:

  var _move = 0.0;
  var _stop = 0.0;
  • Добавим две кнопки. Одна кнопка будет менять направление полета, а другая — останавливать работу шейдера. Но, по сути, они будут менять состояние переменных _move и _stop.

Добавили две кнопки в метод build

         return Stack(
                children: [
                  CustomPaint(painter: AnimRect(shader)),
                  Align(
                    alignment: Alignment.bottomLeft,
                    child: FloatingActionButton(
                      onPressed: () {
                        if (_move == 1) {
                          _move = 0.0;
                        } else {
                          _move = 1;
                        }
                        setState(() {});
                      },
                    ),
                  ),
                  Align(
                    alignment: Alignment.bottomRight,
                    child: FloatingActionButton(
                      onPressed: () {
                        if (_stop == 1) {
                          _stop = 0.0;
                        } else {
                          _stop = 1;
                        }
                        setState(() {});
                      },
                    ),
                  ),
                ],
              );

Передаем _move и _stop в шейдер

...
body: FutureBuilder(
          future: _initShader(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              final shader = snapshot.data!.fragmentShader()
                ..setFloat(0, updateTime)
                ..setFloat(1, 300)
                ..setFloat(2, 300)
                ..setFloat(3, _move)
                ..setFloat(4, _stop);
              return Stack(
                children: [
...

В итоге файл main должен выглядеть так:

main.dart

import 'dart:ui';

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends State with TickerProviderStateMixin {
  var updateTime = 0.0;
  var _move = 0.0;
  var _stop = 0.0;

  @override
  void initState() {
    super.initState();
    createTicker((elapsed) {
      updateTime = elapsed.inMilliseconds / 1000;
      setState(() {});
    }).start();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Colors.black,
        body: FutureBuilder(
          future: _initShader(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              final shader = snapshot.data!.fragmentShader()
                ..setFloat(0, updateTime)
                ..setFloat(1, 300)
                ..setFloat(2, 300)
                ..setFloat(3, _move)
                ..setFloat(4, _stop);
              return Stack(
                children: [
                  CustomPaint(painter: _MySweepPainter(shader)),
                  Align(
                    alignment: Alignment.bottomLeft,
                    child: FloatingActionButton(
                      onPressed: () {
                        if (_move == 1) {
                          _move = 0.0;
                        } else {
                          _move = 1;
                        }
                        setState(() {});
                      },
                    ),
                  ),
                  Align(
                    alignment: Alignment.bottomRight,
                    child: FloatingActionButton(
                      onPressed: () {
                        if (_stop == 1) {
                          _stop = 0.0;
                        } else {
                          _stop = 1;
                        }
                        setState(() {});
                      },
                    ),
                  ),
                ],
              );
            } else {
              return const Center(
                child: CircularProgressIndicator(),
              );
            }
          },
        ),
      ),
    );
  }

  Future _initShader() {
    return FragmentProgram.fromAsset("shader.glsl");
  }
}

class _MySweepPainter extends CustomPainter {
  _MySweepPainter(this.shader);

  final Shader shader;

  @override
  void paint(Canvas canvas, Size size) {
    const Rect rect = Rect.largest;
    final Paint paint = Paint()..shader = shader;
    canvas.drawRect(rect, paint);
  }

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

Переходим в исходный код шейдера shader.glsl.

uniform float time;
uniform vec2 resolution;
uniform float iMove;
uniform float iStop;
out vec4 fragColor;
  • Находим функцию vec3 camera (float time). Данная функция возвращает вектор из трех чисел, рассчитанных с помощью входного параметра time. Добавляем условие, если iMove равна 1, то мы будем возвращать первым числом вектора как константу единицу. Соответственно, не будет происходить смещение камеры. И нам будет казаться, что мы летим только прямо.

vec3 camera(float time)
{
    if(iMove == 1){
         return vec3(1, 5000. + 1500. * sin(0.5 * time), 6000.0 * time);
    }
    return vec3(5000.0 * sin(1.0 * time), 5000. + 1500. * sin(0.5 * time), 6000.0 * time);
}
  • И последнее, нам необходимо с помощью входного параметра iStop останавливать анимацию. Найдем функцию main (), в ней добавим следующее условие:

...
    vec3 campos = camera(time);

       if(iStop == 1){
        campos = camera(1);
        } else {
        campos = camera(time);
        }

    vec3 camtar = camera(time + 0.4);
...

Таким образом, мы будем проверять, если iStop равна единице, то мы вместо переменной time в функцию cameraбудем передавать константу.

В итоге наш шейдер должен быть таким:

shader.glsl

#extension GL_OES_standard_derivatives : enable

precision highp float;

uniform float time;
uniform vec2 resolution;
uniform float iMove;
uniform float iStop;
out vec4 fragColor;

#define iTime time
#define iMouse mouse
#define iResolution resolution

// referred https://www.shadertoy.com/view/4sXGRM

vec3 skytop = vec3(0.05, 0.2, 0.5);

vec3 light = normalize(vec3(0.1, 0.25, 0.9));

vec2 cloudrange = vec2(0.0, 10000.0);

mat3 m = mat3(0.00, 1.60, 1.20, -1.60, 0.72, -0.96, -1.20, -0.96, 1.28);

// hash function
float hash(float n)
{
    return fract(cos(n) * 114514.1919);
}

// 3d noise function
float noise(in vec3 x)
{
    vec3 p = floor(x);
    vec3 f = smoothstep(0.0, 1.0, fract(x));

    float n = p.x + p.y * 10.0 + p.z * 100.0;

    return mix(
        mix(mix(hash(n + 0.0), hash(n + 1.0), f.x),
            mix(hash(n + 10.0), hash(n + 11.0), f.x), f.y),
        mix(mix(hash(n + 100.0), hash(n + 101.0), f.x),
            mix(hash(n + 110.0), hash(n + 111.0), f.x), f.y), f.z);
}

// Fractional Brownian motion
float fbm(vec3 p)
{
    float f = 0.5000 * noise(p);
    p = m * p;
    f += 0.2500 * noise(p);
    p = m * p;
    f += 0.1666 * noise(p);
    p = m * p;
    f += 0.0834 * noise(p);
    return f;
}

vec3 camera(float time)
{
    if(iMove == 1){
         return vec3(1, 5000. + 1500. * sin(0.5 * time), 6000.0 * time);
    }
    return vec3(5000.0 * sin(1.0 * time), 5000. + 1500. * sin(0.5 * time), 6000.0 * time);
}
void main()
{
    vec2 uv = 2. * gl_FragCoord.xy / iResolution.xy - 1.0;
    uv.x *= iResolution.x / iResolution.y;

    float time = (iTime + 13.5 + 44.) * 1.0;
    vec3 campos = camera(time);
       if(iStop == 1){
        campos = camera(1);
    } else {
       campos = camera(time);
    }

    vec3 camtar = camera(time + 0.4);

    vec3 front = normalize(camtar - campos);
    vec3 right = normalize(cross(front, vec3(0.0, 1.0, 0.0)));
    vec3 up = normalize(cross(right, front));
    vec3 fragAt = normalize(uv.x * right + uv.y * up + front);

    // clouds
    vec4 sum = vec4(0, 0, 0, 0);
     for (float depth = 0.0; depth < 10000.0; depth += 200.0)
    {
        vec3 ray = campos + fragAt * depth;
        if (cloudrange.x < ray.y && ray.y < cloudrange.y)
        {
            float alpha = smoothstep(0.5, 1.0, fbm(ray * 0.00025));
            vec3 localcolor = mix(vec3(1.1, 1.05, 1.0), vec3(0.3, 0.3, 0.2), alpha);
            alpha = (1.0 - sum.a) * alpha;
            sum += vec4(localcolor * alpha, alpha);
        }
    }

    float alpha = smoothstep(0.7, 1.0, sum.a);
    sum.rgb /= sum.a + 0.0001;

    float sundot = clamp(dot(fragAt, light), 0.0, 1.0);
    vec3 col = 0.8 * (skytop);
    col += 0.47 * vec3(1.6, 1.4, 1.0) * pow(sundot, 350.0);
    col += 0.4 * vec3(0.8, 0.9, 1.0) * pow(sundot, 2.0);

    sum.rgb -= 0.6 * vec3(0.8, 0.75, 0.7) * pow(sundot, 13.0) * alpha;

    sum.rgb += 0.2 * vec3(1.3, 1.2, 1.0) * pow(sundot, 5.0) * (1.0 - alpha);

    col = mix(col, sum.rgb, sum.a);

    fragColor = vec4(col, 1.0);
}

Вот так выглядит управление шейдером из Flutter.

Исходный код можно посмотреть здесь.

Результат

Используем ShaderMask

Мне бы хотелось попробовать наложить шейдер на виджет Text. Для этого нам необходимо изменить метод build виджета MyApp, таким образом чтобы он работал с виджетом ShaderMask.

Измененный метод build ()


  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Colors.black,
        body: FutureBuilder(
          future: _initShader(),
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              return ShaderMask(
                shaderCallback: (bounds) {
                  return snapshot.data!.fragmentShader()
                    ..setFloat(0, updateTime)
                    ..setFloat(1, bounds.height)
                    ..setFloat(2, bounds.width);
                },
                child: const Center(
                    child: Text(
                  "TEST",
                  style: TextStyle(
                    fontSize: 150,
                    fontWeight: FontWeight.w900,
                    color: Colors.white,
                  ),
                )),
              );
            } else {
              return const Center(
                child: CircularProgressIndicator(),
              );
            }
          },
        ),
      ),
    );
  }

Разберем код построчно:

  • на четвертую и пятую строки для красоты добавим MaterialApp и Scaffold.

  • на одиннадцатую строку возвращаем виджет ShaderMask. У него есть обратный вызов shaderCallback, который в свою очередь возвращает bounds (прямоугольник размером дочернего элемента виджета ShaderMask). А дочерним элементом является виджет Text. Таким образом, мы накладываем шейдер на текст.

Запускаем проект и любуемся работой ShaderMask.

Результат работы виджета ShaderMask

Хотите поделиться своим опытом работы с шейдерами? Жду ваши вопросы в комментариях!

Исходный код урока можно посмотреть по ссылке.

© Habrahabr.ru