Расширение ARB_shader_storage_buffer_object

Традиционно большие объемы данных можно передать в шейдер через uniform-переменные или uniform-блоки (хотя для uniform-блоков размер обычно ограничен 64Кбайтами). Но при этом все эти данные доступны шейдеру только для чтения - шейдер может выполнять только произвольные чтения из них.

Однако в целом ряде случае возникает необходимость е только в чтении, но также и в записи данных. Данную возможность предоставляют расширения ARB_shader_image_load_store и ARB_shader_storage_buffer_object.

Здесь мы рассмотрим только расширение ARB_shader_storage_buffer_object, но на самом деле оба этих расширения имеют много общего, в частности это касается синхронизации. GPU являются массивно-параллельными вычислительными устройствами, поэтому одновременное выполнение огромного числа нитей (на самом деле термин нить - thread - более характерен для таких API как CUDA/OpenCL, а в терминах OpenGL обычно используется термин shader invocation).

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

Подобно тому, как можно сделать uniform-блоки, данные для которых предоставляются через вершинные буфера (точнее UBO, Uniform Buffer Object), также можно объединять данные в буферные блоки в GLSL.

Шейдер может читать, писать и выполнять атомарные операции над этими данными. При этом каждому такому буферному объекту соответствует вершинный буфер, аналогичный буферам для uniform-блоков (UBO). Такие буфера получили называние Shader Storage Buffer Object (SSBO).

Ниже приводится простой пример объявления подобного блока в шейдере - объявляется буфер, содержащий массив элементов заданного типа - NodeType.

struct NodeType
{
    vec4    color;
    float   depth;
    uint    next;
};

layout(binding = 0, std430) buffer myBuffer
{
    NodeType nodes [];
};

Со стороны приложения вводится новый тип буферов - GL_SHADER_STORAGE_BUFFER, который выступает в роли памяти для соответствующих буферных блоков в шейдере. Так же как и UBO эти буфера привязываются к определенным точкам привязки (binding point) через команды glBindBufferBase и glBindBufferRange.

Конкретная реализация OpenGL обладает ограничениями на использования SSBO. Максимальный поддерживаемый размер такого буфера можно узнать вызвав glGetIntegerv с параметром GL_MAX_SHADER_STORAGE_BLOCK_SIZE.

Также есть ограничения на максимальное количество таких блоков, используемых в шейдере данного типа. Эти ограничения можно получить при помощи команды glgetIntegerv с аргументами GL_MAX_VERTEX_SHADER_STORAGE_BLOCKS, GL_MAX_TESS_CONTROL_SHADER_STORAGE_BLOCKS, GL_MAX_TESS_EVALUATION_SHADER_SORAGE)_BLOCKS, GL_MAX_GEOMETRY_SHADER_STORAGE_BLOCKS, GL_MAX_FRAGMENT_SHADER_STORAGE_BLOCKS и GL_MAX_COMPUTE_SHADER_STORAGE_BLOCKS.

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

Для использование SSBO из приложения, нужно создать вершинный буфер, заполнить его данными (или задать его размер) и привязать к заданной точке привязки цели GL_SHADER_STORAGE_BUFFER при помощи команд glBindBufferBase glBindBufferRange.

Следующий шаг - связать соответствующий блок в шейдере с точкой привязки буфера типа GL_SHADER_STORAGE_BUFFER. Это можно сделать двумя способами. Самый простой - прямо в теле шейдера в виде конструкции binding= в описателе layout как показано ниже:

layout(binding=2, std430) buffer FragInfo 
{
    vec4 recs [];
};

Второй способ заключается в задании точки привязки через OpenGL API. Для этого служит вводимая этим расширением функция glShaderStorageBlockBinding.

void glShaderStorageBlockBinding ( GLuint program, GLuint storageBlockIndex, 
                                   GLuint storageBlockBinding );

Параметр storageBlockIndex задает соответствующий буферный блок внутри программы, задаваемой параметром program. Параметр storageBlockBinding задает точку привязки для этого блока к буферу. Для получения индекса блока внутри шейдера можно использовать функцию glGetProgramResourceIndex. Так для привязки блока FragInfo к точке привязки 2 (что в предыдущем примере было сделано через описатель layout в шейдере) можно воспользоваться следующим фрагментом кода:

GLuint index = glGetProgramResourceIndex ( program, GL_SHADER_STORAGE_BLOCK, "FragInfo" );

glShaderStorageBlockBinding ( program, index, 2 );

В GLSL этим расширением были внесены определенные изменения, так было добавлено новое ключевое слово buffer. Для задания поддержки данного расширения в тексте шейдера служит директива #extension.

#extension GL_ARB_shader_storage_block_object: enable

Вместо этого можно просто задать поддержку OpenGL 4.3:

#version 430 core

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

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

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

Как и для UBO для SSBO в теле шейдера в директиве layout можно задать не только точку привязки, но и то, как переменные этого блока будут размещены в памяти. Для этого служат уже знакомые спецификаторы shared, packed, std140, row_major и column_major (см. расширение ARB_uniform_buffer_object).

Кроме этого вводится еще один спецификатор размещения переменных в памяти - std430. Этот спецификатор применим только к буферным блокам и идентичен std140, за исключением того, что в нем нет требования, чтобы смещения, по которым переменные расположены в памяти, были кратны sizeof(vec4).

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

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

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

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

uint atomicAdd     (inout uint mem, uint data);
int  atomicAdd     (inout int  mem, int data);
uint atomicMin     (inout uint mem, uint data);
int  atomicMin     (inout int  mem, int data);
uint atomicMax     (inout uint mem, uint data);
int  atomicMax     (inout int  mem, int data);
uint atomicAnd     (inout uint mem, uint data);
int  atomicAnd     (inout int  mem, int data);
uint atomicOr      (inout uint mem, uint data);
int  atomicOr      (inout int  mem, int data);
uint atomicXor     (inout uint mem, uint data);
int  atomicXor     (inout int  mem, int data);
uint atomicExchange(inout uint mem, uint data);
int  atomicExchange(inout int  mem, int data);
uint atomicCompSwap(inout uint mem, uint compare, uint data);
int  atomicCompSwap(inout int  mem, int  compare, int  data);

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

#extension GL_ARB_shader_storage_buffer_object : require

// Атомарный счетчик хранит число использованных элементов в блоке
layout(binding=0, offset=0) uniform atomic_uint fragmentCounter;

// Размер буфера
uniform uint maxFragmentCount;

// Структура для хранения данных для каждого фрагмента
struct FragmentData 
{
        ivec2 position;
        vec4  color;
};

// Сам блок - это просто массив их FregmentData
layout(std140, binding=2) buffer Fragments 
{
        FragmentData fragments[];
};

in vec4 color;

void main()
{
        uint fragmentNumber = atomicCounterIncrement(fragmentCounter);

        if  ( fragmentNumber < maxFragmentCount ) 
        {
          fragments [fragmentNumber].position = ivec2(gl_FragCoord.xy);
          fragments [fragmentNumber].color    = color;
        }
}

Ниже приводится соответствующий код на С++.

#define MAX_FRAGMENTS     100000 // максимальное количество сохраняемых фрагментов
#define FRAGMENT_SIZE     32     // размер записи под один фрагмент, из-за std140

GLuint fragmentBuffer, counterBuffer;

// Создаем и привязываем буфер для соответствующего блока
// В glBufferData передаем NULL поскольку нам не нужно инициализировать этот буфер

glGenBuffers    (1, &fragmentBuffer);
glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 2, fragmentBuffer);
glBufferData    (GL_SHADER_STORAGE_BUFFER, MAX_FRAGMENTS*FRAGMENT_SIZE,
                 NULL, GL_DYNAMIC_DRAW);

// Создаем буфер под атомарный счетчик
glGenBuffers    (1, &counterBuffer);
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 0, counterBuffer);
glBufferData    (GL_ATOMIC_COUNTER_BUFFER, sizeof(GLuint), NULL, 
                 GL_DYNAMIC_DRAW);

// Установливаем атомарный счетчик в ноль
// И выводим геометрию
GLuint zero = 0;

glBufferSubData(GL_ATOMIC_COUNTER_BUFFER, 0, sizeof(GLuint), &zero);
glUseProgram(program);
glDrawElements(GL_TRIANGLES, numTriangles, GL_UNSIGNED_INT, indices);

Помимо атомарных операций есть еще один способ синхронизации - можно явно задввать доступкак к отдельным переменным внутри блока, так и ко всем пеоременным блока сразу.

coherent buffer Block
{
    readonly vec4 a;
    vec4          b;
};

Поддерживаются следующие описатели - coherent, volatile, restrict, readonly и writeonly.

Описатели readonly и writeonly задают тип доступа к переменной и служат для оптимизации кода шейдера. Попытка выполнить противоречащую этим описателям операцию (например изменить readonly-переменную) приводит к ошибке компиляции.

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

Описатель coherent означает, что доступ к этой переменной происходит согласованно с аналогичным доступом из других нитей. При чтении из соответствующей переменной результат чтения отразит уже завершенные операции записи из других нитей. Аналогично запись в эту переменную будет видна будет видна из операций чтения из других нитей. ОБратите внимание, что выполнение операций чтения и записи носит неопределенный характер, в частности обычно значения переменных могут кэшироваться для увеличения быстродействия. Данный описатель убирает подобное кэширование, что может негативно сказаться на быстродействии.

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

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

void memoryBarrier();

Аналогичная функция - glMemoryBarrier была введена и в OpenGL API.

void glMemoryBarrier ( GLbitfield barriers );

Данная команда обеспечивает явную синхронизацию, гарантирующую, что изменения в памяти, вызванные шейдерами, будут видны в последующих операциях над теми же объектами. Параметр barriers определяет какие именно опреации должны синхронизироваться. Он может включать в себя следующие биты:

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

// Ждем пока мы сможем использовать данные в нашем буфере
glMemoryBarrier(GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT);

// Привязываем буфер как источник данных для вершин
glBindBuffer             (GL_ARRAY_BUFFER, fragmentBuffer);

// Настраиваем атрибуты в вершине
glVertexAttribIPointer   (0, 2, GL_INT, GL_FALSE, FRAGMENT_SIZE, 
                          (void*)0);
glVertexAttribPointer    (1, 4, GL_FLOAT, GL_FALSE, FRAGMENT_SIZE,
                          (void*)16);
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);

// Выводим фрагменты как точечные спрайты
glDrawArrays             (GL_POINTS, 0, count);

Есть еще хорошая статья на английском языке с примерами кода.