GrabDuck

Применение шейдеров OpenGL в QML

:

Этот пост участвует в конкурсе „Умные телефоны за умные посты“

Данный пост посвящен применению шейдеров OpenGL вместе с элементами декларативного языка QML. Тема, на мой взгляд, является актуальной, так как в будущей версии QML 2.0 планируется широко использовать OpenGL, как backend для отрисовки графических элементов интерфейса. Написание шейдеров — тема непростая и целью данного поста является то, чтобы в первую очередь человек, прочитав её, мог сразу же что-то попробовать сделать интересное для себя и поэкспериментировать, получив, например, вот такие примеры:

В конце я приведу полезные ссылки, где Вы сможете посмотреть материал для дальнейшего, более глубокого изучения данной темы, если она конечно Вас заинтересует, и реализовать еще более интересные шейдеры, применив их вместе с элементами языка QML. Работу с шейдерами можно рассмотреть на примере различных элементов QML: ShaderEffectItem, множества классов Qt3D, так же использующих OpenGL и т.д. В данном посте я продемонстрирую несколько примеров, используя элемент ShaderEffectItem вместе с ShaderEffectSource.

Далее следует план данной статьи в целом:
Установка элемента ShaderEffectItem и ShaderEffectSource
Немного теории шейдеров
Связывание элементов QML с шейдерами
Пример 1. Реализация градиента с помощью шейдеров
Пример 2.1 Простейшая анимация
Пример 2.2 Создание меню с анимацией
Пример 3. Выделяем некоторую область текстуры в зависимости от указателя мыши
Пример 4. Смешивание двух изображений
Заключение
Полезные ссылки

Начнём c его установки необходимых элементов.

Установка необходимых плагинов

Для начала необходимо проверить установлены у вас ли все компоненты OpenGL.
1) Переходим по ссылке и вы увидете адрес в git репозитории где лежит shadersplugin. Если ничего не поменялось, то он такой:
git://gitorious.org/qt-labs/qml1-shadersplugin.git
2) Делаем git clone git://gitorious.org/qt-labs/qml1-shadersplugin.git
3) Заходим в папку и делаем make install (так я делаю под ОС Linux, посмотрите как устанавливаются аналогичные элементы под Вашу ОС). Если каких-то компонентов OpenGL нет то возниткнуть проблемы с установкой. Если у Вас эта проверка вызывает какие-то затруднения, просто создайте пустое Qt приложение и добавьте в файл проекта (*.pro) строку: QT += declarative opengl. Если всё скомпилируетсятся значит проблем при установке быть не должно.
Немного теории шейдеров

Знакомые с понятием шейдеров могут пропустить эту небольшую главу. В ней я сделаю краткий обзор этой темы. Зачем же нужны шейдеры? Говоря простым языком, шейдеры позволяют «вмешиваться» программисту в процесс отрисовки примитивов, т.е. вносить изменения в этапы работы конвейера (о котором будет сказано чуть ниже), посредством написания собственно кода. Для написания шейдеров существует язык GLSL (OpenGL Shading Language), созданный комитетом OpenGL. Его синтаксис базируется на языке программирования C. GLSL был разработан специально для того, чтобы программисты имели контроль над программируемыми точками конвейера OpenGL, представляющего собой последовательность стадий, через которые проходят команды OpenGL). Как один и вариантов конвейера показан на рисунке ниже:

Вершина любого объекта передаётся в конвейер. Сначала выполняется преобразование координат (Vertex Transformation) — применение мировой, видовой и проекционной матриц поступившей вершины. Это как раз относится к работе вершинного шейдера. После выполнения этих операций наступает компоновка примитива (Assembly): на этом этапе пространственные координаты (х, у, z) преобразовываются с помощью матриц размерностью (4 х 4). Основная задача — получение экранных, двухмерных координат из трехмерных(мировых) координат. В этой части конвейера, вершины группируются в треугольники и подаются в растеризатор (Rasterization). Растеризатор делит треугольник на фрагменты (пиксели), для которых интерполируются текстурные координаты и цвет. Затем наступает работа фрагментного шейдера. Он отвечает за определение цвета каждого пикселя экрана внутри области, ограниченной контуром спроецированной на экран отрисовываемой поверхности. После обработки всех этих методов полученный фрагмент помещается в буфер кадра, который впоследствии выводиться на экран (Pixel updates).
Как вы уже поняли шейдеры бывают двух типов: вершинные и фрагментные (или их еще называют пиксельным). Вершинный шейдер выполняется раньше и обрабатывает каждую вершины, фрагментный же выполняется для каждого пикселя, которому поставлен в соответствие некоторый набор атрибутов, таких как цвет ( .r, .g, .b, .a), глубина, текстурные координаты ( .x, .y, .z, .w или .s, .t, .p, .q). Точкой входа в шейдер является функция void main(). Если в программе используется оба типа шейдеров, то имеется две точки входа main. Перед входом в функцию main выполняется инициализация глобальных переменных. В GLSL определены специальные типы переменных:
uniform — связь шейдера с внешними данными (в случае QML это будут свойства элементов (property) ), следует отметить, что этот тип переменных только для чтения;
varying — этот тип переменых необходим для связи фрагментного шейдера с вершинным шейдером, то есть для передачи данных от вершинного шейдера к фрагментному. В вершинном шейдере они могут изменяться, а во фрагментном доступны только для чтения;
attribute — переменные глобальной области видимости;
Так же следует упомянуть о некоторых элементах языка GLSL, которые встретятся в примерах ниже:
sampler2D — один из типов языка GLSL, представляющий текстуру (есть еще sampler1D, sampler3D, samplerCube, sampler1Dshadow, sampler2Dshadow );
vec4 texture2D(sampler2D s, vec2 coord) — функция, используемая для чтения пикселя из текстуры s, с текстурными координатами coord.
gl_FrontColor — это вектор, в который записываются конечные цветовые данные текстуры и который доступен только во фрагментном шейдере.
Так же следует упомянуть, что в GLSL определено множество встроенных функций, ориентированных на вычисления, в частности для работы с векторами и матрицами. В ходе разбора примеров, приведённых ниже, про некоторые функции будет рассказано.

Связывание элементов QML с шейдерами

Обязательным требованием для работы шейдеров с QML элементами является установка OpenGL для отрисовки, в объекте класса QDeclarativeView:
QmlApplicationViewer viewer;
...
QGLWidget* glWidget = new QGLWidget(format);
...
viewer.setViewport(glWidget);
...

Данный кусок кода взят из сгенерированной в QtCreator'е (мастером Qt Quick приложений) главной функции приложения — main, класс QmlApplicationViewer наследуется от QDeclarativeView. После каждого примера я буду приводить ссылку на полный исходный код.

Как было сказано выше, для демонстрации работы с шейдерам OpenGL будет использован элемент ShaderEffectItem, позволяющий вносить изменения в отображение на экране различных элементов QML, используя механизмы OpenGL. Доступен он в модуле Qt.labs.shaders 1.0 (так как находится в разработке), но уже сейчас можно попробовать использовать его. Для написания кода вершинного и фрагментного шейдеров определены соответственно свойства (типа string) fragmentShader и vertexShader.

ShaderEffectSource необходим для указания QML компонента, который будет доступен в шейдере. У него в основном будут использоваться свойства sourceItem и hideSource. Первое указывает на конкретный элемент QML (его идентификатор), который будет подвергаться «воздействию» шейдеров, а hideSource «говорит», что исходный элемент будет скрыт, когда будет применён эффект шейдеров.

Разрешается определять один или несколько ShaderEffectItems как источник(и) для другого ShaderEffectItems, но не следует объявлять ShaderEffectItems как дочерний элемент, элемента, определённого в source, так как это скорее всего приведёт к зацикливанию перерисовки.

В QML есть возможность определить свои свойства для элемента (с помощью property), и они также будут доступны как переменные в программах-шейдерах. Это происходит автоматически, если имена их совпадают и переменная в шейдере объявлена с уже упоминавшимся спецификатором uniform — так называемое связывание. Когда дойдём до примеров, сразу будет ясен этот момент.

Если рассмотреть весь процесс максимально абстрактно, то графическое представление QML элемента передаётся текстурой в вершинный и фрагментный шейдеры и далее выдаётсz конечный результат, который будет отображаться на экране. Соответственно, можно вносить изменения в отрисовку этой текстуры в программах-шейдерах (это мы снова возвращаемся к тому, зачем вообще нужны шейдеры).

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

Пример 1. Реализация градиента с помощью шейдеров

Начнём с очень простого примера. В QML есть достаточно часто применяемый элемент Rectangle и у него имеется свойство gradient. В примере ниже, я хочу показать как можно достичь градиента с помощью механизма шейдеров. Итак, будет создан элемент Rectangle размерами 360 на 360. Так же необходимо добавить элемент ShaderEffectItem, как дочерний к Rectangle, с обязательно указанным свойством anchors.fill со значением parent. Тем самым мы говорим, что шейдер «покрывает» весь родительский элемент. Код представлен ниже:

import QtQuick 1.0
import Qt.labs.shaders 1.0

Rectangle {
    width: 360
    height: 360
    ShaderEffectItem {
        anchors.fill: parent
        fragmentShader: "
            varying highp vec2 qt_TexCoord0;
            void main(void)
            {
                lowp vec4 c0 = vec4( 1.0, 1.0, 1.0, 1.0 );
                lowp vec4 c1 = vec4( 1.0, 0.0, 0.0, 1.0 );
                gl_FragColor = mix( c0, c1, qt_TexCoord0.y );
            }
        "
    }
}


Теперь обратим внимание на свойство fragmentShader — в нём указан текст программы фрагментного шейдера. Во-первых, мы определяем переменную varying highp vec2 qt_TexCoord0, которую мы получаем от вершинного шейдера, хотя он у нас и не определен, реализация по умолчанию у него есть и мы можем получать оттуда данные. qt_TexCoord0 определяет, как я понял, текстурные координаты сцены в целом (буду рад если меня кто-то подправит и скажет как это правильно называется, с точки зрения компьютерной графики). Теперь обратимся к функции main. Определим в ней два вектора c0 содержащий белый цвет (цвет представляется как, rgba) и c1 — красный цвет, а дальше присваиваем выходному вектору gl_FragColor значение, полученное для каждого пикселя с помощью функции mix — функции линейной интерполяции между двумя значениями:

mix (vec4 x, vec4 y, float a) — выражается формулой: x * ( 1.0 - a )+y * a

Изменяющимся параметром a здесь будет значение .y текстурного вектора, соответствующее векторной коордитнате по оси y. Соответственно, результат выполнения будет следующим:

Так как qt_TexCoord0.y представляет векторную координату по оси y, то градиент будет сверху вниз, если, например мы хотим градиент слева направо, то нужно заменить строку:

 
gl_FragColor = mix( c0, c1, qt_TexCoord0.y );

на

 gl_FragColor = mix( c0, c1, qt_TexCoord0.x );

.x означает векторную координату по x. А если мы хотим просто закрасить всё красным цветом, без всякого градиента, то будет вот такой код (тут уже абсолютно все пиксели закрашиваются красным цветом):
 
void main(void)
{
   gl_FragColor = vec4 ( 1.0, 0.0, 0.0, 1.0 );
}

Вместо векторных координат x и y, можно использовать текстурные s и t соответственно. Результат будет аналогичным. Исходный код доступен здесь
Попробуем дальше сделать какую-то анимацию с помощью механизма шейдеров.
Пример 2.1 Простейшая анимация
Применим шейдеры для работы с изображением планеты:

Сделаем, немного конечно глупый эффект, но всё же… Как будто планета бьётся как сердце. Для начала необходимо, с помощью ShaderEffectSource, определить свойство в элементе Item, например под названием source. В самом же шейдере укажем uniform lowp sampler2D source; тем самым сделав связывание нашей текстуры (картинки планеты) с кодом шейдера и возможность вносить изменения в её отрисовку. Для создания любой анимации необходимо изменение каких-то данных во времени. Для этого я буду использовать элемент QML PropertyAnimation. А какие же данные нам надо изменять? Здесь я хочу показать пример, как вместо данных одного пикселя можно подставлять данные другого и тем самым получить эффект анимации. Т.е. например, у нас есть пиксель с текстурной координатой x,y (а также цветовыми данными), а мы вместо него подставим какой-то соседний пиксель (со своими уже цветовыми данными) и выбирать мы его будем как некое приращение, полученное по какой-то функции, пусть это будет функция sin. Следовательно, в качестве изменяемых данных желательно иметь угол от 0 до 360 градусов. Тем самым, если посмотреть на код ниже в PropertyAnimation для свойства angle задано изменение от 0.0 до 360.0.


import QtQuick 1.0
import Qt.labs.shaders 1.0

Item {
     width: img.width
     height: img.height

     Image {
          id: img
          source: "images/space.jpg"
     }

     ShaderEffectItem {
      property variant source: ShaderEffectSource {
           sourceItem: img;
           hideSource: true
      }
      anchors.fill: img
      fragmentShader: "
       varying highp vec2 qt_TexCoord0;
       uniform lowp sampler2D source;
       uniform highp float angle;
       void main() {
        highp float wave = 0.01;
        highp float wave_x = qt_TexCoord0.x + wave * sin( radians( angle + qt_TexCoord0.x * 360.0 ) );
        highp float wave_y = qt_TexCoord0.y + wave * sin( radians( angle + qt_TexCoord0.y * 360.0 ) );
        highp vec4 texpixel = texture2D( source, vec2( wave_x, wave_y ) );
        gl_FragColor = texpixel;
      }"

     property real angle : 0.0
     PropertyAnimation on angle {
          to: 360.0
          duration: 800
          loops: Animation.Infinite
     }
    }
}

Амплитуду колебаний зададим с помощью highp float wave = 0.01. Зачем нужна функция radians я думаю объяснять не нужно. Но если мы подставим просто значение угла angle картинка просто будет двигаться разные стороны, а нам же необходимо что-то более эффектное — «биение». Текстурные координаты изменяются от 0 до 1, соответственно для каждого пикселя будет своё «домножение в функции sin на угол 360». В wave_x и wave_y буду записываться координаты пикселя из некоторой соседней окрестности, взятые по оси x и по оси y соответственно. С помощью texture2D( source, vec2( wave_x, wave_y ) ); мы берём значения этого нового пикселя и записываем их в уже знакомый нам gl_FragColor.
Вот видео результата применение такого фраментного шейдера для картинки планеты:

Исходный код доступен здесь
Пример 2.2 Создание меню с анимацией

Выглядит это достаточно красиво и я решил попробовать применить этот же эффект для кнопок меню. Чтобы при наведении на кнопку был эффект, схожий, с планетой. В данном описании представлен пример создания меню в QML, из вот этого руководства. Каждая кнопка описывается в Button.qml. Я немного добавил в её описание работы с шейдерами. Код фрагментного шейдера почти не отличается от примера выше, только я чуть чуть увеличил амплитуду колебаний wave = 0.02:
Файл Button.qml:

import QtQuick 1.0
import Qt.labs.shaders 1.0

Item
{
    width: but.width
    height: but.height
    property alias text: textItem.text

    Rectangle {
        id: but
        width: 130;
        height: 40
        border.width: 1
        radius: 5
        smooth: true

        gradient: Gradient {
            GradientStop { position: 0.0; color: "darkGray" }
            GradientStop { position: 0.5; color: "black" }
            GradientStop { position: 1.0; color: "darkGray" }
        }

        Text {
            id: textItem
            anchors.centerIn: parent
            font.pointSize: 20
            color: "white"
        }

        MouseArea {
            property bool ent: false
            id: moousearea
            anchors.fill: parent
            onEntered: {
                ent = true
            }
            onExited: {
                ent = false
                effect.angle = 0.0
            }
            hoverEnabled: true
        }
    }

    ShaderEffectItem {
        id: effect
        property variant source: ShaderEffectSource {
            sourceItem: but;
            hideSource: true
        }
        anchors.fill: but
        property real angle : 0.0
        PropertyAnimation on angle {
            id: prop1
            to: 360.0
            duration: 800
            loops: Animation.Infinite
            running: moousearea.ent
        }

        fragmentShader: "
         varying highp vec2 qt_TexCoord0;
         uniform lowp sampler2D source;
         uniform highp float angle;
         void main() {
          highp float wave = 0.02;
          highp float wave_x = qt_TexCoord0.x + wave * sin( radians( angle + qt_TexCoord0.x * 360.0 ) );
          highp float wave_y = qt_TexCoord0.y + wave * sin( radians( angle + qt_TexCoord0.y * 360.0 ) );
          highp vec4 texpixel = texture2D( source, vec2( wave_x, wave_y ) );
          gl_FragColor = texpixel;
        }"
    }
}

Ну и сам файл menu.qml


import QtQuick 1.0
import Qt.labs.shaders 1.0

Item {
     width: 150
     height: 190

     Column {
         anchors.horizontalCenter: parent.horizontalCenter
         Button { text: "Apple"  }
         Button { text: "Red" }
         Button { text: "Green" }
         Button { text: "Blue" }
      }
}

Хочу обратить внимание на то, что в событии onExited необходимо обязательно сбрасывать свойство угла angle элемента effect в 0.0, иначе угол, подставляемый в вычисление соседнего пикселя будет начинаться расчитываться не с 0, а с последнего значения и получится не совсем то, что мы ожидаем. В итоге получается вот такой эффект:

Исходный код доступен здесь

Пример 3. Выделяем некоторую область текстуры в зависимости от указателя мыши

Далее, я хочу привести пример изменения цвета пикселей некоторого участка изображения. Участок будет определяться положением указателя мыши, который будет центром окружности с радиусом в 50 пикселей. И эта окружность будет иметь цвета пикселей отличные от исходных.
Во-первых, в данном примере необходимо определить 3 свойства в элементе ShaderEffectItem:
property real xPos: 65.0
property real yPos: 65.0
property real radius: 50
Они определюят соотвественно координаты мыши, передеваемые в код шейдера и радиус окружности. Для отслеживания перемещения мыши определен элемент MouseArea и обработка события onPositionChanged. Ниже приведён исходный код и далее даются пояснения:

Rectangle {
     width: img.width
     height: img.height

     Image {
        id: img
        source: "images/nature.jpg"
     }

     ShaderEffectItem {
         id: effect
         anchors.fill: parent
         MouseArea {
             id: coords
             anchors.fill: parent
             onPositionChanged: {
                 effect.xPos = mouse.x
                 effect.yPos = coords.height - mouse.y
             }
         }
         property real xPos: 65.0
         property real yPos: 65.0
         property real radius: 50
         property int widthImage: img.width
         property int heightImage: img.height
         property variant source: ShaderEffectSource {
             sourceItem: img;
             hideSource: true
         }

         fragmentShader:
         "varying highp vec2 qt_TexCoord0;
          uniform highp float xPos;
          uniform highp float yPos;
          uniform highp float radius;
          uniform highp int widthImage;
          uniform highp int heightImage;
          highp vec2 pixcoords = qt_TexCoord0.st * vec2( widthImage, heightImage );
          uniform sampler2D source;
          void main(void)
          {
           lowp vec4 texColor = texture2D(source, qt_TexCoord0.st);
           lowp float gray = dot( texColor, vec4( 0.6, 0.5, 0.1, 0.0 ) );
           if ( ( pow( ( xPos - pixcoords.x ), 2 ) + pow( ( yPos - pixcoords.y ), 2 ) ) 
                  < pow( radius, 2 ) )
           {
             gl_FragColor = vec4( gray, gray, gray, texColor.a) ;
           }
           else
           {
             gl_FragColor = texture2D( source, qt_TexCoord0 );
           }
          }"
    }
 }

Можно заметить, что применяется функция возведения в квадрат pow (она работает аналогично функции с таким же названием в C/C++ из библиотеки math) для того чтобы определить попадает ли точка данного пикселя с координатой ( pixcoords.x; pixcoords.y ) в окружность с центром в точке xPos и yPos и радиусом radius.
Соответственно, если координата пикселя попадает в нашу окружность, то результатом выдаём пиксель, скалярно умноженный на вектор, определяющий серый цвет (функция dot осуществляет скалярное произведение). Если нет, то конкретный пиксель никак не изменяется. Опять же можно заметить, как связаны паременные QML элемента со переменными программы-шейдера — они имеют одинаковое имя и эквивалентные типы: real эквивалентен highp float.
Результат выполнения следующий:

Стоит отметить, что здесь мы применяем условные операторы (точно такие же как в языке C), которые доступны в язык GLSL.
Исходный код доступен здесь

Пример 4. Смешивание двух изображений (текстур)

Пусть у нас имеется две картинки:
Кофейная кружка

и кофейные зёрна:

Мы хотим сделать фон в виде зёрен кофе и на нём кофейная кружка. Для решения данной задачи, нам снова нужно будет работать с текстурными координатами. В ShaderEffectItem будет определено два изображения texture0 и texture1, как элементы ShaderEffectSource. В коде фрагментного шейдера эти два изображения будут храниться как две текстуры в uniform sampler2D texture0 и uniform sampler2D texture1. В переменные s1 и s2 мы получаем текстурные координаты каждого пикселя первого изображния и второго, соответственно, как показано в коде ниже:

import QtQuick 1.0
import Qt.labs.shaders 1.0

Rectangle {
    width: coffee.width
    height: coffee.height

    Image {
        id: coffee
        source: "images/coffee.jpg"     
    }

    Image {
        id: granules
        source: "images/granules.jpg"
     }

     ShaderEffectItem {
         anchors.fill: parent
         id: effect
         property variant texture0: ShaderEffectSource {
             sourceItem: coffee;
             hideSource: true
         }
         property variant texture1: ShaderEffectSource {
             sourceItem: granules;
             hideSource: true
         }
         fragmentShader:
         "
         varying highp vec2 qt_TexCoord0;
         uniform sampler2D texture0;
         uniform sampler2D texture1;
         void main(void)
         {
          vec4 s1 = texture2D( texture0, qt_TexCoord0.st );
          vec4 s2 = texture2D( texture1, qt_TexCoord0.st ) ;
          gl_FragColor = mix( vec4( s1.r, s1.g, s1.b, 1.0 ), 
                                        vec4( s2.r * 0.6, s2.g * 0.6, s2.b * 0.6, 0.4 ),  
                                        0.35 );
         }"
    }
}

В результирующий вектор gl_FrontColor будет записываться уже знакомый нам (в ходе создания градиента) результат линейной интерполяции двух векторов, содержащих параметры цвета пикселя. Причём каждый канал цвета в текстуре s2 (кофейные зёрна, будет умножен на 0.6, так как он нам необходим как фон). В итоге мы имеем вот такой результат выполнения:

Можно очень много экспериментальных вариантов сделать с параметрами функции mix и значениями векторов (например четвёртный элемент вектора, отвечающий за прозрачность, 1.0 и 0.4 в примере выше) и получить разные, интересно смешанные текстуры.

Исходный код доступен здесь

В заключение хочу сказать, что я считаю, приведённые примеры достаточно простыми и тривиальными, но с другой стороны, они могут быть полезны тем, кто совсем не знаком с этой тематикой и хочет попробовать что-то сделать похожее.

Заключение

Подводя итоги, можно сказать, что благодаря возможности написания программ-шейдеров, мы получаем очень гибкие механизмы для работы с наиболее важными этапами обработки графики OpenGL при отрисовки элементов QML. Стоит так же отметить, что язык GLSL, как уже упоминалось, очень похож на C, но как сказано в официальной спецификации отличия есть. Например, отсутствуют указатели (данные в функцию передаются по значению), нельзя никаким образом использовать рекурсию и т.д. Следует помнить, что плохо или неправильно написанная программа-шейдер может очень сильно сказаться на производительности. Работа данных плагинов протестирована на платформах: Symbian^3, Maemo 5, Mac OS X, Windows 7 и Ubuntu. Сами по себе требования к платформе представляют собой версию Qt SDK 4.7.x и поддержку QtOpenGL. Будущая версия QML — QML2 в своём Scene Graph будет поддерживать API комбинирующий GL/GLES шейдеры с QML кодом. Можно рассмотреть в Qt 5.0 элемент ShaderEffect. Если я правильно понял это и есть некое подобие того, о чём я писал выше.
Полезные ссылки