Вадим Великодный Статьи и заметки

Вычисления на GPU с помощью OpenGL

Как известно, современная видеокарта — это устройство, которое позволяет быстро обрабатывать огромное количество данных за счёт параллельных вычислений. Расчётами в ней занимаются сотни (а в новых моделях и тысячи) процессоров. То, что они работают одновременно, позволяет получить огромное быстродействие. Разумеется, эти процессоры не настолько мощные и универсальные, как центральный процессор компьютера (CPU). Но для обработки изображений часто требуется лишь набор только самых базовых команд и операций из линейной алгебры. Так что, видеокарты берут вопреки завету Суворова не умением, а числом. И тут возникает естественное желание всей это мощью воспользоваться для решения каких-то задач не связанных с графикой. И такая возможность, конечно, есть.

Уже давно существуют такие библиотеки, как CUDA от Nvidia или универсальная OpenCL. В современные математические библиотеки (например, в Tensorflow от Google) также встраивается поддержка вычислений на графических процессорах (GPU), что на порядки (!) ускоряет расчёты. Такой подход называется GPGPU.

Однако, на мобильных устройствах всё не так радужно. Конечно, в современном смартфоне графическим процессором никого не удивишь, но часто дело ограничивается только поддержкой графической библиотеки OpenGL. Конечно, Apple и Google предлагают решения для ускорения расчётов — Metal и RenderScript — но они не кроссплатформенные. В обеих ОС есть неофициальная поддержка OpenCL — стандарта на научные вычисления с помощью GPU, — но тут ключевое слово — неофициальная.

Тем не менее, выход существует. Для того, чтобы выполнять какие-то расчёты, часто достаточно и возможностей современного OpenGL. Конечно, даже простое удвоение элементов матрицы превратится в простыню кода, но зато выполняться оно будет гораздо быстрее, чем при удвоении с последовательным обходом элементов. Плюс такая программа будет кроссплатформенной, и её (после некоторых модификаций, конечно) можно будет запускать и в Android, и в iOS, и в Linux.

Попробуем написать программу, решающую уже упомянутую задачу параллельного удвоения элементов матрицы с помощью OpenGL. Весь код писался под ОС GNU/Linux, но он кроссплатформенной (я надеюсь) и без особых проблем может быть портирован на любую другую ОС, где есть OpenGL.

Я предполагаю, что читатель немного знаком с принципами работы OpenGL и языком C++. Без этого понять материал статьи будет намного сложнее.

OpenGL ES 2.0

В настоящее время у OpenGL есть несколько версий. Сразу хочу оговориться, что речь пойдёт об OpenGL ES 2.0. Семейство стандартов OpenGL ES — это API для обработки 3D-графики на встраиваемых устройствах. ES здесь означает как раз Embedded Systems, то есть встраиваемые системы. Чаще всего это мобильные телефоны. Есть несколько версий OpenGL ES, но версия 1.0 безнадёжно устарела, а возможности 3.0 нам пока не понадобятся.

OpenGL ES 2.0 основана на стандарте OpenGL 2.0, из которого выкинули всё лишнее или тяжело реализуемое на мобильном устройстве. Несмотря на значительные сокращения, этот стандарт (или библиотека, если угодно) очень сложен для изучения. Кривая обучения крутая не только из-за обилия понятий, которые нужно знать и задействовать даже для того, чтобы просто нарисовать треугольничек. Дополнительную сложность составляет поиск статей или руководств. И если для современного OpenGL есть очень неплохие учебники, то по GPGPU на OpenGL ES нет практически ничего.

Я почти не имел дел с OpenGL и потратил несколько выходных просто чтобы заставить код, описанный в статье, работать. Конечно, когда все команды выстроены по порядку, всё кажется простым и логичным. Но поверьте, перед тем как написать каждую строчку приходилось долго читать спецификацию и вникать. Но не всё так грустно — через какое-то время, когда уже прочитана спецификация и есть какой-то опыт, начинаешь понимать всю стройную структуру и обучение идёт куда быстрее и проще. Совет начинающим — просто потерпите недельку и во всём разберётесь.

Если вы когда-то изучали OpenGL (например, в вузе) и ещё помните, что там можно рисовать с помощью glBegin или glEnd точки и многоугольники, то у меня для вас плохая новость. В современном OpenGL всё совершенно по-другому. И учить всё нужно заново с нуля.

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

Основы

Разберёмся основными понятиями. Изложение будет упрощённым по понятным причинам.

Итак, графический ускоритель (будем называть его GPU) — это устройство, которое умеет быстро выполнять параллельные вычисления над огромными массивами данных. Это именно отдельное устройство со своей памятью и своими процессорами. То есть, раз память отдельная, GPU ничего не знает о переменных и массивах, которые вы объявите у себя в программе. Данные, с которыми предстоит работать, нужно сперва загрузить в память GPU. Области памяти GPU называются буферами.

Обычно это массивы вершин элементарных треугольников, из которых строятся трёхмерные объекты, и текстуры. Последние — это обычные плоские изображения, которые «натягиваются» на трёхмерные объекты. Вершины задают форму, а текстуры — окраску и рисунок. К слову, дистрибутивы компьютерных игр такие большие именно из-за обилия текстур в высоком разрешении. И хотя кирпич можно задать восемью вершинами, для того, чтобы насладиться его видом на 4К-мониторе нужна очень качественная (а значит, и большая) картинка.

Все изображения, которые обрабатывает GPU, он сперва помещает в память. Мы видим на мониторе далеко не все результаты расчётов. Это используется, например, в технике двойной буферизации. Пока мы смотрим на картинку на мониторе, GPU уже готовит следующий кадр. И когда он будет нарисован, останется только отобразить его, что можно сделать очень быстро и эффективно.

Более того, можно так настроить работу GPU, чтобы он вообще ничего не отображал. А изображения-результаты мы можем просто загрузить из его памяти в память нашей программы.

Разберёмся теперь с тем, что именно делает GPU. Его работу можно условно представить в виде конвейера со следующими стадиями:

  1. Загружаются координаты всех вершин, текстуры, какие-то вспомогательные данные и параметры. Каждый вид данных помечен: вершины отдельно, текстуры отдельно и т. д.
  2. Каждая вершина параллельно и независимо (это важно!) обрабатывается специальной программой, называемой вершинным шейдером. Эту программу пишем мы сами. Без нас GPU ничего делать не будет. Шейдеры в OpenGL обычно пишутся на языке GLSL, который мало чем отличается от языка C. В итоге получается, что в нашей программе на языке, например, C будут фрагменты программы на языке GLSL. К счастью, отдельный компилятор для GLSL устанавливать не придётся. Драйвер видеокарты умеет компилировать шейдеры «на лету» и загружать их в память GPU.
  3. Результат обработки массива вершин — примитивы. К ним относятся, например, точки, отрезки, треугольники. Также вершинный шейдер отвечает за назначение им цветов и наложение текстур. Все примитивы заданы в векторном виде. То есть треугольник определяется координатами в пространстве. Пока что это просто математические описания объектов.
  4. Выполняется растеризация, то есть преобразование вида на нарисованную из примитивов сцену в растровое изображение. То есть абстрактный треугольник превращается в группу окрашенных фрагментов. О фрагменте можно думать как о пикселе, но это более общий объект: он хранит информацию о цвете, об альфа-канале, о координатах элемента изображения и многое другое. А пиксель — это то, что мы потом видим на экране. Можно считать, что фрагмент — это данные, необходимые для генерации пикселя.
  5. Каждый фрагмент обрабатывается фрагментным шейдером. С его помощью мы можем, например, размыть изображение, заменив значение цвета текстуры на арифметическое среднее цветов соседей. Но надо помнить, что фрагменты обрабатываются независимо и параллельно. Поэтому посчитать, например, средний цвет всего изображения — непростая задача. Можно, конечно, в шейдере в двух циклах найти арифметическое среднее. Но эти два цикла выполнятся многократно для каждого фрагмента. И никакого прироста быстродействия, разумеется, не будет.
  6. Пофрагментные операции.
  7. Запись результата во фреймбуфер — область памяти, куда складываются результаты. Фреймбуфер может быть связан с каким-то окном на экране, а может и не быть связан. Этим уже занимается не OpenGL, а операционная система и механизмы отрисовки окон в них разные (а в некоторых и окон нет). Фреймбуферов может быть несколько.

Важно заметить, что OpenGL — это API с состоянием. То есть, когда мы работаем GPU у него есть некоторые внутренние параметры, которые меняются отправляемыми нами командами. Например, чтобы начать работать с каким-то фреймбуфером, нужно сперва сделать его текущим. Тогда все последующие команды будут работать именно с ним. Поэтому порядок команд очень важен.

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

Базовые принципы GPGPU

Итак, как же нам выполнить произвольные (General Purpose, GP) вычисления на GPU? Для этого нужно просто понять три базовых принципа, которые мы разберём ниже.

Массивы — это текстуры

Все данные, которые мы будем обрабатывать, мы будем хранить в виде текстур. Ведь что такое текстура? Это изображение, то есть двумерный массив пикселей. (Если быть точным, то трёхмерный, учитывая три цветовых канала и прозрачность.) По сути это матрица, а с ними нам и нужно работать.

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

Использование текстур для хранения массивов накладывает серьёзные ограничения (которые становятся ещё более серьёзными в урезанных ES-версиях OpenGL). Например, в OpenGL ES 2.0, который мы договорились использовать, данные мы можем хранить максимум в виде четырёх байтов (RGBA — красный зелёный, синий и прозрачность). Можно, конечно, выкрутиться, и как-то закодировать вещественное число, но нужно помнить, что в шейдере эти байты будут обрабатываться независимо и придётся их декодировать обратно. Впрочем, если мы решаем задачу цифровой обработки изображения, то это не проблема. Исходное изображение очевидным образом используется как текстура.

У этой проблемы, конечно, есть решения. Одно из них — использовать расширения OpenGL. Как известно, у базового OpenGL возможностей мало, но производители «железа» добавляют много новых. Все расширения зарегистрированы и мы всегда можем проверить, поддерживает наше оборудование какое-то расширение или нет.

Скажем, есть расширение (и не одно), которое позволяет загружать текстуру как массив вещественных чисел. Но увы, оно есть на настольных компьютерах и рассчитывать на то, что оно поддерживается мобильным устройством, мы не можем.

Впрочем, уже в OpenGL ES 3.0 (не говоря уж о «взрослых» OpenGL, без ES) есть поддержка чисел с плавающей точкой в текстурах.

Ядра — это шейдеры

Программу для поэлементных вычислений над матрицами в GPGPU называют ядром. Не лишним будет ещё раз напомнить, что ядра применяются к элементам матрицы независимо и параллельно. То есть, в идеале время обработки всей матрицы будет равно времени работы одного ядра. (В жизни это, конечно, не так. На все элементы ядер не хватает, потому приходится выполнять обработку блоками.)

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

Вычисления — это отрисовка

Процесс вычислений — это процесс отрисовки фрагментов в фреймбуфере. То есть, чтобы запустить шейдеры, нам сначала нужно нарисовать что-то. Обычно это прямоугольник, на который наложена наша текстура. Но так как основной примитив у нас треугольник, то вместо прямоугольника рисуем два прямоугольных треугольника.

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

Задача

Попробуем решить следующую задачу. Пусть дана матрица размером H×W = 64×64. Выделим под неё память и заполним суммами номеров строк и столбцов. Работать будем с однобайтовыми числами, так что от сумм будем брать только младший байт. Заодно выделим память для результата.

Двумерный массив должен быть расположен в памяти одним блоком построчно. В C и C++ мы можем только выделить память под одномерный массив. Но это не проблема. Просто вместо a[i][j] будем писать a[i*W+j]. Первое слагаемое пропускает i строчек от 0 до i-1, а второе — смещается в строке с номером i на позицию j. Да, не очень удобно, но быстро привыкаешь.

Данные будут однобайтовыми, но вспомним, что в текстуре каждый пиксель имеет формат RGBA. Процессоры архитектуры x86 хранят целые числа в формате little-endian, поэтому если рассматривать эти четыре байта как одно 4-байтовое число, то младшим байтом будет R. Там и будем хранить наше значение.

Я буду писать на C++, но этот код будет легко портировать на C, заменив new на malloc, delete[] на free, а cout на printf.

const int H = 64;
const int W = 64;

uint32_t *data = new uint32_t[H * W];
uint32_t *result = new uint32_t[H * W];

for (int i = 0; i < H; i++)
    for (int j = 0; j < W; j++)
        data[i*W+j] = (i + j) % 256;

Здесь используется тип uint32_t из <cstdint>, чтобы гарантировать, что элементы будут 32-битные, то есть 4-байтовые. Фактически, это псевдоним для int.

И сразу выведем младшие байты элементов массива на экран. Чтобы не загромождать терминал выведем только верхний левый угол 8×8.

for (int i = 0; i < 8; i++) {
    for (int j = 0; j < 8; j++)
        std::cout << std::setw(4) << data[i*W+j];
    std::cout << std::endl;
}

(Полный исходный код со всеми заголовочными файлами будет приведён в конце.)

В результате на экране появится следующее:

   0   1   2   3   4   5   6   7
   1   2   3   4   5   6   7   8
   2   3   4   5   6   7   8   9
   3   4   5   6   7   8   9  10
   4   5   6   7   8   9  10  11
   5   6   7   8   9  10  11  12
   6   7   8   9  10  11  12  13
   7   8   9  10  11  12  13  14

Создание контекста

Команды OpenGL, как уже было сказано, абстрактны и не привязаны к какому-то конкретному устройству. Поэтому, чтобы они отправлялись на GPU, нужно создать контекст. Если мы пользуемся, например, библиотекой GLUT, то она делает это за нас. Но она привязывает его к окну, а мы хотим создать безоконное приложение.

Для создания безоконного контекста (offscreen context) можно воспользоваться библиотекой EGL, которая для этого и предназначена.

Информация о дисплее, контексте и поверхности, на которой будет происходить отрисовка будет храниться в следующих основных переменных.

EGLDisplay eglDisplay;
EGLContext eglContext;
EGLSurface eglSurface;

Сперва получим информацию о текущем дисплее. На нём мы рисовать не будем, но это нужно для создания контекста.

eglDisp  = eglGetDisplay(EGL_DEFAULT_DISPLAY);

Инициализируем EGL.

eglInitialize(eglDisplay, nullptr, nullptr);

Теперь создадим конфигурацию поверхности на которой будем рисовать. Это будет не окно, а пиксельный буфер (pbuffer) в памяти. Укажем также глубину цвета и версию OpenGL.

Нам также потребуются две вспомогательные переменные для хранения конфигурации.

EGLint numConfigs;
EGLConfig eglConfig;

Конфигурация задаётся в виде массива пар параметр — значение. Признак конца конфигурации — EGL_NONE.

const EGLint configAttribs[] = {
    EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
    EGL_BLUE_SIZE, 8,
    EGL_GREEN_SIZE, 8,
    EGL_RED_SIZE, 8,
    EGL_DEPTH_SIZE, 8,
    EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, // !
    EGL_NONE
};

eglChooseConfig(eglDisplay, configAttribs, &eglConfig, 1, &numConfigs);

Параметр EGL_RENDERABLE_TYPE очень важен! Если указать что-то кроме EGL_OPENGL_ES2_BIT, то OpenGL ES 2.0 не будет работать как нужно.

Следующий шаг — создание поверхности на основе нашей конфигурации. Отображать на ней мы всё равно ничего не будем, поэтому можно задать размеры равными 1×1.

const EGLint pbufferAttribs[] = {
    EGL_WIDTH, 1,
    EGL_HEIGHT, 1,
    EGL_NONE,
};

eglSurface = eglCreatePbufferSurface(eglDisplay, eglConfig, pbufferAttribs);

Делаем OpenGL текущим API.

eglBindAPI(EGL_OPENGL_API);

Наконец, создаём контекст и делаем его активным для текущего потока.

const EGLint contextAttribs[] = {
    EGL_CONTEXT_CLIENT_VERSION, 2, // !
    EGL_NONE
};

eglContext = eglCreateContext(eglDisplay, eglConfig,  EGL_NO_CONTEXT, contextAttribs);
eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext);

Параметр EGL_CONTEXT_CLIENT_VERSION также очень важен. Если указать неверное значение, то шейдеры просто не будут компилироваться.

В конце программы, когда контекст не нужен, удаляем его.

eglMakeCurrent(eglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
eglDestroyContext(eglDisplay, eglContext);
eglDestroySurface(eglDisplay, eglSurface);
eglTerminate(eglDisplay);

eglDisplay = EGL_NO_DISPLAY;
eglSurface = EGL_NO_SURFACE;
eglContext = EGL_NO_CONTEXT;

Для краткости я не обрабатывал ошибки. В реальных приложениях это, конечно же, стоит делать. Код ошибки после последней команды можно получить с помощью функции eglGetError.

Создание фреймбуфера

Раз мы не будем выводить изображение на экран, нам потребуется новый фреймбуфер (FBO, famebuffer object), в который и будут записываться результаты. У каждого фреймбуфера есть уникальный номер, который назначается ему при создании. Будем хранить его в переменной fb.

GLuint fb;
glGenFramebuffers(1, &fb);
glBindFramebuffer(GL_FRAMEBUFFER, fb);

Параметр 1 во второй строке означает, что мы создаём один фреймбуфер. Команда glBindFramebuffer сразу после создания привязывает его (bind), то есть делает его текущим.

Матрицы (текстуры)

Создание

Сперва узнаем, текстуры какого размера вообще поддерживаются.

GLint maxtexsize;
glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxtexsize);
std::cout << "Max texture size = " << maxtexsize << std::endl;

Про помощи функций семейства glGet* можно получить любые предельные значения, которые определяются оборудованием. Впрочем, в простых случаях проверку делать не обязательно, так как стандарт даёт минимальные гарантии. Скажем, для текстур гарантируется, оборудование будет поддерживать размер не меньше 64×64.

Мой ноутбук выдал значение 16384. Это значит, что я могу загружать текстуры размером 16384×16384. Нам хватит с запасом.

С каждой текстурой связан некий объект, имеющий уникальный идентификатор (имя, если пользоваться терминологией OpenGL). И перед началом работы с текстурой нужно этот объект создать.

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

GLuint tex[2];
glGenTextures(2, tex);

Параметр 2 — это число создаваемых текстурных объектов. Текстуру tex[0] будем использовать для входа, а tex[1] для выхода.

Загрузка изображения в текстуру

В текстуру tex[0] теперь нужно загрузить числа из data. Это делается в несколько шагов. Сперва привяжем эту текстуру, чтобы сделать её текущей.

glBindTexture(GL_TEXTURE_2D, tex[0]);

Число пикселей в текстуре может отличаться от числа пикселей в отображаемом объекте. Поэтому GPU придётся её растягивать или уменьшать. Скажем, что для этого нужно использовать алгоритм ближайшего соседа. Но можно указать и другой. Например, билинейную интерполяцию. У нас число пикселей будет совпадать, так что для нас это неважно.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);

GPU умеет работать с текстурами только с размерами, равными степеням двойки. Если это не так, текстуру нужно дополнить до ближайшего подходящего размера.

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

Теперь нужно выделить память в GPU для хранения текстуры нашего размера (H×W).

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, W, H, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);

Здесь довольно много параметров, суть которых можно посмотреть в документации. Главное, что делает команда — выделяет память под изображение указанного размера с пикселями в формате RGBA.

Осталось только загрузить в выделенную память наши данные.

glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, W, H, GL_RGBA, GL_UNSIGNED_BYTE, data);

Здесь GL_RGBA и GL_UNSIGNED_BYTE указывают уже на формат данных в data. OpenGL сам выполнит преобразование.

Не забудем выделить память и под вторую текстуру.

glBindTexture(GL_TEXTURE_2D, tex[1]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, W, H, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);

Привязка к фреймбуферу

Укажем теперь, что фреймбуфер должен складывать результат в текстуру tex[1]. Она уже привязана, так что достаточно одной команды.

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex[1], 0);

Программа и шейдеры

Наконец, мы подошли к самому интересному — шейдерам. Шейдеров может быть несколько и не все из них могут использоваться в текущем конвейере. Шейдеры, образующие конвейер, называются программой. Их, кстати, тоже можно сформировать больше одной и включать по необходимости.

В нашем случае у нас будет одна программа, содержащая один вершинный и один фрагментный шейдер.

Создадим соответствующие объекты.

GLuint program = glCreateProgram();

GLuint vs = glCreateShader(GL_VERTEX_SHADER);
GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);

GLSL

И вершинный, и фрагментный шейдеры пишутся на языке GLSL, который очень похож на C. Даже главная функция в каждом из них называется main(). Но, конечно же, есть и много отличий. В интернете можно найти руководства по GLSL и краткие обзоры. Я же перечислю только то, что нам пригодится.

Во-первых, в GLSL больше типов данных. Кроме привычных int, float и bool (double нет, так как его точность избыточна для графики) присутствуют также:

  • vec2, vec3, vec4 — векторы разной длины из элементов типа float. Их можно конструировать прямо в выражениях. Например: vec3(1.0, 2.0, 3.0).
  • ivec2, ivec3, ivec4 — целочисленные векторы.
  • mat2, mat3, mat4 — квадратные матрицы из элементов типа float.

Все эти типы поддерживают поэлементное сложение, умножение и так далее. А с помощью функции dot можно выполнять матричное умножение.

Можно создавать структуры и массивы. Отдельно стоит отметить тип Sampler2D. Это специальный объект, который извлекает из текстуры значения по указанным координатам.

Для вещественных чисел можно задавать точность. Это может ускорить расчёты и сэкономить память.

Во-вторых, переменные могут иметь следующие квалификаторы:

  • const — константа,
  • attribute — такие переменные используются только в вершинном шейдере. Им присваиваются значения на основе передаваемых в шейдер из основной программы атрибутов,
  • uniform — неизменные параметры задаваемые в программе и используемые в шейдере,
  • varying — связь между вершинным и фрагментным шейдером.

В-третьих, функции вроде sin() или abs() встроены в сам язык. Ничего подключать не требуется. Есть и более экзотические (но очень полезные) функции вроде clamp(), mix() или step(). Рекомендую ознакомиться со списком функций и операций, чтобы знать, что можно сделать встроенными средствами.

Конечно, для написания эффективных (да и вообще сколько-нибудь сложных шейдеров) этих знаний явно недостаточно, но, надеюсь, код шейдеров после этого раздела станет чуть понятнее.

Вершинный шейдер

Код вершинного шейдера можно хранить в отдельном файле. Но если код небольшой, то часто его размещают в основной программе в виде строковой константы.

Рассмотрим код вершинного шейдера:

attribute vec4 position;
varying vec2 tex_coord;
void main() {
    gl_Position = position;
    tex_coord = 0.5 * vec2(position.x + 1.0, position.y + 1.0);
}

Его цель — задать координаты вершин помещая их в зарезервированную переменную gl_Position. Мы ничего особо вычислять не будем, а просто скопируем их из атрибута position, который позже передадим шейдеру.

Заметьте, что нет никаких циклов. Этот код будет параллельно выполнен для каждой вершины.

Кроме определения координат вершин нам нужно сопоставить им координаты на текстуре. Тут уже придётся сделать нехитрые вычисления. Дело в том, что диапазон координат вершин равен -1.0..1.0, а диапазон координат пикселей текстуры (их называют текселями) — 0.0..1.0. Во второй строке функции main() выполняется преобразование координат.

Заметьте, что когда мы рисуем треугольник, мы задаём три вершины. Когда он растеризуется, фрагментов, конечно, будет больше трёх. Координаты текстуры (как и другие varying-переменные) внутри треугольника будут интерполироваться между вершинами. Так что нам не нужно будет думать какой тексель соответствует какому фрагменту.

Для вставки в главную программу оформим код в виде строковой константы.

const GLchar *vs_src =
    "attribute vec4 position;\n"
    "varying vec2 tex_coord;\n"
    "void main() {\n"
    "  gl_Position = position;\n"
    "  tex_coord = 0.5 * vec2(position.x + 1.0, position.y + 1.0);\n"
    "}\n";

Фрагментный шейдер

В нашей программе фрагментный шейдер получает от программы текстуру, а от вершинного шейдера координату на текстуре для обрабатываемого фрагмента. При желании можно получить координаты самого фрагмента, прочитав встроенную переменную gl_fragCoord.

Нужно заметить, что значения цветовых компонент передаются в вещественном формате. То есть диапазон 0..255 отображается на 0.0..1.0. При записи результата во фреймбуфер происходит обратное преобразование.

Также мы будем передавать из программы uniform-переменную a, содержащую коэффициент, на который мы и будем умножать младший байт (напомню, это компонента R). Если мы хотим удваивать элементы, то надо будет в программе задать a = 2.0 и запустить вычисления.

Рассмотрим код шейдера.

precision mediump float;
uniform sampler2D img;
varying vec2 tex_coord;
uniform float a;
void main() {
  vec4 color = texture2D(img, tex_coord);
  gl_FragColor = color * vec4(a, 1.0, 1.0, 1.0);
}

Здесь задана средняя точность для всех вещественных переменных.

Сэмплер, связанный с нашей текстурой я назвал img, хотя можно было назвать как угодно. Также здесь снова объявлена varying-переменная tex_coord. И, наконец, задан параметр a. Значения всем uniform-переменным, как уже было сказано, будут присвоены в самой программе. В шейдере же мы считаем их известными.

Сам шейдер тоже предельно прост. В первой строке функции main() с помощью встроенной функции texture2D мы извлекаем цвет соответствующего текселя. Во второй, умножаем младший байт (R) на a, остальные (G, B, A) оставляем неизменными.

Здесь нет return, цвет фрагмента возвращается через встроенную переменную gl_FragColor.

Код для вставки в программу.

const GLchar *fs_src =
    "precision mediump float;\n"
    "uniform sampler2D img;\n"
    "varying vec2 tex_coord;\n"
    "uniform float a;\n"
    "void main() {\n"
    "  vec4 color = texture2D(img, tex_coord);\n"
    "  gl_FragColor = color * vec4(a, 1.0, 1.0, 1.0);\n"
    "}\n";

Компиляция и компоновка

Теперь, когда у нас есть код обоих шейдеров, их нужно скомпилировать и скомпоновать в программу. Это можно сделать один раз при запуске программы. Перекомпилировать перед каждым использованием не нужно.

Сперва загружаем исходные тексты.

glShaderSource(vs, 1, &vs_src, NULL);
glShaderSource(fs, 1, &fs_src, NULL);

Выполняем компиляцию.

glCompileShader(vs);
glCompileShader(fs);

Перед тем, как присоединять шейдеры к программе, присвоим атрибуту position номер 0. Потом по этому номеру мы будем передавать в него массив.

glBindAttribLocation(program, 0, "position");

Добавляем шейдеры к программе и компонуем её.

glAttachShader(program, vs);
glAttachShader(program, fs);

И, наконец, сделаем программу текущей выполняемой.

glUseProgram(program);

Обработка ошибок

До сих пор я (самонадеянно) не проверял значения кодов ошибок, возвращаемые glGetError(). Это сделано специально, чтобы статья не стала чересчур длинной. Хотя, конечно, в ходе подготовки это была у меня самая ходовая функция. Однако для шейдеров я всё же приведу фрагмент кода, который позволит получить сообщения об ошибках компилятора GLSL.

Пример кода для получения сообщения об ошибке компиляции вершинного шейдера (vs).

GLint shader_compiled;
GLsizei log_length;
GLchar message[1024];

glGetShaderiv(vs, GL_COMPILE_STATUS, &shader_compiled);
if (shader_compiled != GL_TRUE)
{
    glGetShaderInfoLog(vs, 1024, &log_length, message);
    std::cout << "Error: " << message << std::endl;
}

Подготовка программы

Для начала очистим наш фреймбуфер заполнив его нулями.

glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);

После этих двух строк все четыре цветовые компоненты будут обнулены.

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

glViewport(0, 0, W, H);

Загрузим текстуры в шейдер.

Текстур можно создавать сколько угодно, но на одновременное использование есть ограничение. Когда мы используем текстуру в шейдере, извлечением данных из неё занимается текстурный блок (texture unit). Таких блоков несколько, но их ограниченное количество. При загрузки текстуры в блок нужно сперва сделать его, потом связать с ним текстуру.

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

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, tex[0]);

Чтобы передать данные в некоторую uniform-переменную нужно сперва узнать её позицию в коде. Это делается с помощью функции glGetUniformLocation.

Итак, для img будем использовать текстурный блок номер 0, а значение a зададим равным 2.0.

GLint imgloc = glGetUniformLocation(program, "img");
glUniform1i(imgloc, 0);

GLint aloc = glGetUniformLocation(program, "a");
glUniform1f(aloc, 2.0);

Суффиксы 1i и 1f у функции glUniform указывают на количество и тип передаваемых через переменную значений.

С помощью uniform-переменных можно сделать шейдеры более универсальными. Менять их значения можно после компоновки программы. Оно сохраняется в памяти до следующей компоновки. То есть, его не нужно обновлять перед каждой отрисовкой.

Вычисления (отрисовка)

Осталось выполнить отрисовку, чтобы запустить шейдеры на выполнение. Для этого нужно нарисовать что-то. Мы нарисуем прямоугольник, захватывающий всю видимую область. Координаты вершин этого прямоугольника поместим в массив вершин. (Заметьте, что координаты трёхмерные.)

const GLfloat vertices[] = {
    -1.0, -1.0, 0.0,
    -1.0,  1.0, 0.0,
     1.0,  1.0, 0.0,
     1.0, -1.0, 0.0
};

Вершины записаны именно в таком порядке потому что на самом деле они будут использоваться для отрисовки «цепочки» треугольников (это задано параметром GL_TRIANGLE_FAN). Каждая следующая вершина соединяется с двумя предыдущими.

Поместим эти вершины в нулевой массив атрибутов.

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vertices);

Осталось только включить нулевой массив атрибутов (а именно его ждёт вершинный шейдер, если помните) и запустить отрисовку.

glEnableVertexAttribArray(0);
glDrawArrays(GL_TRIANGLE_FAN, 0, 4);

Получение результата

Результаты отрисовки помещаются во фреймбуфер в текстуру tex[1]. Кстати, можно использовать её повторно уже как исходный массив.

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

glReadPixels(0, 0, W, H, GL_RGBA, GL_UNSIGNED_BYTE, result);

И для проверки выведем верхний левый угол этого массива.

for (int r = 0; r < 8; ++r) {
    for (int c = 0; c < 8; ++c) {
        std::cout << std::setw(4) << (uint32_t)result[r*W + c] % 255;
    }
    std::cout << std::endl;
}

Должно получиться следующее:

   0   2   4   6   8  10  12  14
   2   4   6   8  10  12  14  16
   4   6   8  10  12  14  16  18
   6   8  10  12  14  16  18  20
   8  10  12  14  16  18  20  22
  10  12  14  16  18  20  22  24
  12  14  16  18  20  22  24  26
  14  16  18  20  22  24  26  28

То есть, значения элементов удвоились, как мы и хотели!

Как видите, всё не так сложно. :)

Исходный код и компиляция

Для удобства прикладываю полный исходный текст программы.

Для компиляции просто выполните команду:

g++ gpgpu.cpp -lGLESv2 -lEGL -o gpgpu

Это создаст исполняемый файл gpgpu, который и будет выполнять все вычисления.

Для компиляции в Ubuntu нужно установить пакет libgles2-mesa-dev (ну и компилятор, конечно).

Полезные источники