steps3D - Tutorials - Расширение ARB_shader_image_load_store

Расширение ARB_shader_image_load_store

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

Расширение ARB_shader_image_load_store (вошедшее в состав OpenGL, начиная с версии 4.2) предоставляет шейдерам возможность произвольного доступа (как чтение, так и запись, включая атомарные операции) по произвольным адресам для одного слоя текстуры в mipmap-пирамиде.

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

Т.е. могут возникать различные проблемы с синхронизацией и данное расширение специально вводит для этого новые функции. Если вы хотите лучше понять подобные "подводные камни", то знакомство с CUDA или OpenCL вам сильно поможет. Кроме того, и CUDA и OpenCL имеют очень хорошую интеграцию с OpenGL и поэтому многие вычисления лучше перенести на них, а не делать в шейдерах.

Расширение ARB_shader_image_load_store вводит в OpenGL новое понятие - изображение (image). Это слой в mipmap-пирамиде какой-либо текстуры (или грань кубической карты или элемент в текстурном массиве). Шейдеры могут свободно читать и писать в эти изображения.

Для того, чтобы шейдер мог обратиться к изображению нужно в шейдер ввести соответствующую uniform-переменную. Для чтения из текстур OpenGL использует переменные типа sampler* (sampler1D, sampler2D, samplerCube и т.д.). Аналогично для доступа к изображениям используются uniform-переменные типа image* (image1D, image2D, imageCube и т.д.).

layout(rgba32f) uniform image2D im;

Приведенное выше описание задает двухмерное изображение формата GL_RGBA32F (четыре компоненты тппа float) с именем im.

Как и для текстур, для изображений вводится понятие блока (image unit), к каждому блоку можно привязать ровно одно изображение. Блоки нумеруются начиная с нуля, максимальное количество блоков ограничено и зависит от реализации, его можно получить при помощи следующего фрагмента кода:

GLuint maxImageUnits;

glGetIntegerv(GL_MAX_IMAGE_UNITS, &maxImageUnits);

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

void glBindImageTexture(GLuint unit, GLuint texture, GLuint level,
                        GLboolean layered, GLuint layer,
                        GLenum access, GLenum format);

Параметр unit задает блок (image unit), к которому нужно привязать слой текстуры texture (обратите внимание, что для этого необязательно привязывать эту текстуру к какому-нибудь текстурному блоку). Блок задается целым числом, начиная от нуля (в отличии от текстурных блоков, задаваемых константами GL_TEXTURE0, GL_TEXTURE1 и т.д.).

Параметр texture задает привязываемую текстуру. В случае, когда эта текстура является текстурным массивом, то можно привязать какой-то конкретный элемент массива или же всю текстур (имеется в виду конкретный слой в mipmap-пирамиде данной текстуры).

Если параметр layered равен GL_TRUE, то производится привязка всего уровня данной текстуры. В противном случае мы можем привязать конкретный элемент текстурного массива/слой 3D-текстуры или грань кубической карты. Параметр layer в этом случае и задает привязываемый элемент/слой/грань.

Для кубических текстур при layered=GL_FALSE привязываемая грань задается параметром layer. Для массива кубических карт конкретный элемент n и грань face задаются при помощи следующих формул:

n    = floor( layer / 6 )
face = layer % 6

Параметр format задает то, как будут интерпретироваться элементы изображения в памяти, допустимыми форматами являются rgba32f, rgba16f, rgba16f, rg16f, r11f_g11f_b10f, r32f, r16f, rgba32ui, rgba16ui, rgb10_a2ui, rgba8ui, rg32ui, rg16ui, rg8ui, r32ui, r16ui, r8ui, rgba32i, rgba16i, rgba8i, rg32i, rg16i, rg8i, r32i, r16i, r8i, rgba16, rgb10_a2, rgba8, rg16, rg8, r16, r8, rgba16_snorm, rgba8_snorm, rg16_snorm, rg8_snorm, r16_snorm и r8_snorm. При этом каждый формат напрямую соответствует формату OpenGL. Так формат rgb10_a2ui соответствует GL_RGB10_A2UI.

Параметр access определяет вид доступа к текстуре и принимает одно из следующих значений - GL_READ_ONLY, GL_WRITE_ONLY и GL_READ_WRITE.

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

Тип текстуры i j k Грань/слой
GL_TEXTURE_1D x - - -
GL_TEXTURE_2D x y - -
GL_TEXTURE_3D x y z -
GL_TEXTURE_RECTANGLE x y - -
GL_TEXTURE_CUBE_MAP x y - z
GL_TEXTURE_BUFFER x - - -
GL_TEXTURE_1D_ARRAY x - - y
GL_TEXTURE_2D_ARRAY x y - z
GL_TEXTURE_CUBE_MAP_ARRAY x y - z

При обращении к изображению мы читаем/пишем сразу четырехмерный вектор - vec4, ivec4 или uvec4. При этом в зависимости от формата текстуры лишние компоненты отбрасываются, недостающие берутся из вектора (0,0,0,1) (как это принято в OpenGL).

Каждая переменная типа image* должна иметь свой описатель формата, допустимые форматы приведены ранее.

layout(rgba32f) uniform image2D im;
layout(r32i) uniform image2D im2;

При этом, когда слой текстуры привязывается к блоку, но задаваемый формат не обязан совпадать с внутренним форматом текстуры, например можно использовать формат GL_R32UI для доступа к текстуре формата GL_RGBA8.

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

Следующая таблица задает размеры и классы для поддерживаемых форматов.

Формат изображения Размер Класс Формат пиксела/тип
GL_RGBA32F 128 4x32 GL_RGBA, GL_FLOAT
GL_RGBA16F 64 4x16 GL_RGBA, GL_HALF
GL_RG32F 64 2x32 GL_RG, GL_FLOAT
GL_RG16F 32 2x16 GL_RG, GL_HALF
GL_R11_G11F_B10F 32 (a) GL_RGB, GL_UNSIGNED_INT_10F_11F_11F_REV
GL_R32F 32 1x32 GL_RED, GL_FLOAT
GL_R16F 16 1x16 GL_RED, GL_HALF
GL_RGBA32UI 128 4x32 GL_RGBA_INTEGER, GL_UNSIGNED_INT
GL_RGBA16UI 64 4x16 GL_RGBA_INTEGER, GL_UNSIGNED_SHORT
GL_RGB10_A2UI 32 (b) GL_RGBA_INTEGER, GL_UNSIGNED_INT_2_10_10_10_REV
GL_RGBA8UI 32 4x8 GL_RGBA_INTEGER, GL_UNSIGNED_BYTE
GL_RG32UI 64 2x32 GL_RGBA_INTEGER, GL_UNSIGNED_INT
GL_RG16UI 32 2x16 GL_RGBA_INTEGER, GL_UNSIGNED_SHORT
GL_RG8UI 16 2x8 GL_RGBA_INTEGER, GL_UNSIGNED_BYTE
GL_R32UI 32 1x32 GL_RGBA_INTEGER, GL_UNSIGNED_INT
GL_R16UI 16 1x16 GL_RGBA_INTEGER, GL_UNSIGNED_SHORT
GL_R8UI 8 1x8 GL_RGBA_INTEGER, GL_UNSIGNED_BYTE
GL_RGBA32I 128 4x32 GL_RGBA_INTEGER, GL_INT
GL_RGBA16I 64 4x16 GL_RGBA_INTEGER, GL_SHORT
GL_RGBA8I 32 4x8 GL_RGBA_INTEGER, GL_BYTE
GL_RG32I 64 2x32 GL_RG_INTEGER, GL_INT
GL_RG16I 32 2x16 GL_RG_INTEGER, GL_SHORT
GL_RG8I 16 2x8 GL_RG_INTEGER, GL_BYTE
GL_R32I 32 1x32 GL_RED_INTEGER, GL_INT
GL_R16I 16 1x16 GL_RED_INTEGER, GL_SHORT
GL_R8I 8 1x8 GL_RED_INTEGER, GL_BYTE
GL_RGBA16 64 4x16 GL_RGBA, GL_UNSIGNED_SHORT
GL_RGB10_A2 32 (b) GL_RGBA, GL_UNSIGNED_INT_10_10_10_2_REV
GL_RGBA8 32 4x8 GL_RGBA, GL_UNSIGNED_BYTE
GL_RG16 32 2x16 GL_RG, GL_UNSIGNED_SHORT
GL_RG8 16 2x8 GL_RG, GL_UNSIGNED_BYTE
GL_R16 16 1x16 GL_RED, GL_UNSIGNED_SHORT
GL_R8 8 1x8 GL_RED, GL_UNSIGNED_BYTE
GL_RGBA16_SNORM 64 4x16 GL_RGBA, GL_SHORT
GL_RGBA8_SNORM 32 4x8 GL_RGBA, GL_BYTE
GL_RG16_SNORM 32 2x16 GL_RG, GL_SHORT
GL_RG8_SNORM 16 2x8 GL_RG, GL_BYTE
GL_R16_SNORM 16 1x16 GL_RED, GL_SHORT
GL_R8_SNORM 8 1x8 GL_RED, GL_BYTE

Если формат текстуры не совпадает с форматом изображения (но форматы совместимы), то все операции доступа к памяти просто интерпретируют данные из памяти в соответствии со своим форматом.

Переменные типа image* могут иметь дополнительные спецификаторы, служащие для управления доступом к памяти - coherent, volatile, restrict и const.

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

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

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

Спецификатор restrict означает, что к соответствующей текстуре можно обратиться только через эту image*-переменную. Данный спецификатор позволяет компилятору применять различные оптимизации, например объединение или переупорядочение обращений к текстуре.

Спецификаторы readonly и writeonly сообщают компилятору о типе доступа к соответствующей текстуре.

layout(rgba16f) uniform image2D restrict a;
layout(r32f) coherent uniform image2D i;

При описании функций доступа к изображениям мы будем использовать следующие обозначения: gvec4 обозначает 4-мерный вектор (vec4, ivec4, uvec4), а gimage* обозначает image*, iimage* и uimage*.

Для чтения из изображения используется функция imageLoad:

gvec4 imageLoad(readonly gimage1D image,        int  coord);
gvec4 imageLoad(readonly gimage2D image,        ivec2 coord);
gvec4 imageLoad(readonly gimageRect image,      ivec2 coord);
gvec4 imageLoad(readonly gimage3D image,        ivec3 coord);
gvec4 imageLoad(readonly gimageCube image,      ivec3 coord);
gvec4 imageLoad(readonly gimageBuffer image,    int  coord);
gvec4 imageLoad(readonly gimage1DArray image,   ivec2 coord);
gvec4 imageLoad(readonly gimage2DArray image,   ivec3 coord);
gvec4 imageLoad(readonly gimageCubeArray image, ivec3 coord);

Для записи значений в текстуру используется функция imageStore:

void imageStore(writeonly gimage1D image,        int   coord, gvec4 data);
void imageStore(writeonly gimage2D image,        ivec2 coord, gvec4 data);
void imageStore(writeonly gimageRect image,      ivec2 coord, gvec4 data);
void imageStore(writeonly gimage3D image,        ivec3 coord, gvec4 data);
void imageStore(writeonly gimageCube image,      ivec3 coord, gvec4 data);
void imageStore(writeonly gimageBuffer image,    int   coord, gvec4 data);
void imageStore(writeonly gimage1DArray image,   ivec2 coord, gvec4 data);
void imageStore(writeonly gimage2DArray image,   ivec3 coord, gvec4 data);
void imageStore(writeonly gimageCubeArray image, ivec3 coord, gvec4 data);

Также можно использовать следующие атомарные операции над отдельными текселами (только над 32-битовыми целочисленными значениями) - imageAtomicAdd, imageAtomicMin, imageAtomicMax, imageAtomicAnd, imageAtomicOr, imageAtomicXor, imageAtomicExchange и imageAtomicCompSwap. Ниже приводятся описания этих функций для случая двухмерного изображения:

uint imageAtomicAdd     (gimage2D image, ivec2 coord, uint data);
int  imageAtomicAdd     (gimage2D image, ivec2 coord, int  data);
uint imageAtomicMin     (gimage2D image, ivec2 coord, uint data);
int  imageAtomicMin     (gimage2D image, ivec2 coord, int  data);
uint imageAtomicMax     (gimage2D image, ivec2 coord, uint data);
int  imageAtomicMax     (gimage2D image, ivec2 coord, int  data);
uint imageAtomicAnd     (gimage2D image, ivec2 coord, uint data);
int  imageAtomicAnd     (gimage2D image, ivec2 coord, int  data);
uint imageAtomicOr      (gimage2D image, ivec2 coord, uint data);
int  imageAtomicOr      (gimage2D image, ivec2 coord, int  data);
uint imageAtomicXor     (gimage2D image, ivec2 coord, uint data);
int  imageAtomicXor     (gimage2D image, ivec2 coord, int  data);
uint imageAtomicExchange(gimage2D image, ivec2 coord, uint data);
int  imageAtomicExchange(gimage2D image, ivec2 coord, int  data);
uint imageAtomicCompSwap(gimage2D image, ivec2 coord, uint compare, uint data);
int  imageAtomicCompSwap(gimage2D image, ivec2 coord, int  compare, int  data);

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

void memoryBarrier();

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

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

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

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

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

layout(early_fragment_tests) in;

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

void glMemoryBarrier( GLbitfield barriers);

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

Кроме этого расширения есть еще полезное расширение ARB_shader_image_size, которое вводит в зяык для написания шейдеров функцию imageSize, возвращающую размер для заданного изображения (переменной типа *image*.

Для одномерных и изображений (*image1D) и буферов изображений (*imageBuffer) функция imageSize возвращает целое число (int). Для двухмерных изображений (*image2D) и массивов одномерных изображений (*image1DArray) возвращается значение типа ivec2, содержащее ширину и высоту в текселах. Для кубических изображений (*imageCube) также возвращается значение типа ivec2, задающее размер одной грани.

Для массивов двухмерных изображений (*image2DArray), массивов кубических изображений (*imageCiubeArray) и трехмерных изображений (*image3D) возвращается значение типа ivec3.

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

#version 430 core

// Счетчик для учета уже выделенных элементов в imgBuffer
layout ( binding = 0, offset = 0 ) uniform atomic_uint counter;

// Начала списка фрагментов для каждого пиксела
layout ( r32ui ) uniform coherent uimage2D imgHead;

// Списки фрагментов, каждый элемент списка содержит индекс предыдущего
// 0 - конец списка
layout (rg32ui) uniform coherent uimageBuffer imgBuffer;

in  float attr;
out vec4  color;

main ()
{
	// Забираем очередной свободный элемент из буфера
    uint idx = atomicCounterIncrement ( counter ) + 1;

	// Если в буфере есть свободное место
    if ( idx < imageSize ( imgBuffer )
    {
		// Вписываем себа как начало списка и получаем старое начало
        uint prev = imageAtomicExchange ( imgHead, gl_FragCoord.xy, idx );

		// Заполняем элемент буфера, задавая индекс предыдущего элемента
        imageStore ( imgBuffer, idx, uvec2 ( uint(attr), prev ) );
    }

    color = vec4 ( attr ); 
}

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

Расширение EXT_shader_atomic_float добавляет поддержку функций imageAtomicAdd, imageAtomicExchange, imageAtomicLoad и imageAtomicStore с аргументом с плавающим типом. Кроме того, оно добавляет поддержку атомарных операций со значениями с плавающей точкой (кроме atomicMin и atomicMax ). Расширение EXT_shader_atomic_float2 добавляет поддержку imageAtomicMin и imageAtomicMax для значений с плавающим типом. Также это расширение добавляет поддержку атомарных операций atomicMin и atomicMax для чисел с плавающей точкой.