Uniform-буфера и uniform-блоки в OpenGL 3.30

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

Существует несколько способов организации работы с uniform-переменными в таких случаях. Одним из таких методов является организация таких переменных в массивы и использования расширения EXT_bindable_uniform. Это позволяет привязать к такому массиву VBO и задавать данные через него. Однако организация данных в виде однородных массив не всегда удобна.

Расширение ARB_uniform_buffer_object (вошедшее в состав OpenGL 3.1) предлагает и другой вариант. Это расширение позволяет организовывать uniform-переменные в так называемые uniform-блоки, при этом в качестве источника данных для этих блоков выступают VBO с типов GL_UNIFORM_BUFFER.

Объединение uniform-переменных в блоки

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

uniform MyBlock
{
    mat4  m;
	vec3  v;
	bool  flag;
	uvec4 mask;
};

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

В описании блока при помощи директивы layout можно явно задать один из трех способов для управления размещением переменных внутри соответствующего буфера. Поддерживаются следующие способы - packed, shared и std140.

layout (std140) uniform MyBlock
{
    mat4  m;
	vec3  v;
	bool  flag;
	uvec4 mask;
};

Формат packed эффективно размещает переменные в памяти, при этом способ размещения зависит от реализации. Кроме того, неиспользуемые переменные могут быть отброшены.

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

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

Переменные типа bool и uint хранятся как значения типа GLuint, при этом ненулевое значение соответствует true. Типы float и int хранятся как соответствующие значения для CPU. Вектора из N элементов хранятся как N последовательно идущих значений соответствующего типа. Таким образом bvec3 будет храниться как три значения типа GLuint.

Матрица CxR column-major хранится как массив из C векторов-столбцов из R элементов типа float. При этом расстояние в байтах между соседними векторами может не совпадать с размером вектора и обозначается как matrix stride.

Аналогично row-major матрица CxR хранится как массив из R векторов-строк из C элементов. Массив из скаляров, векторов, матриц хранится последовательно, начиная с нулевого элемента. При этом расстояние между подряд идущими элементами может не совпадать с размером элемента и называется array stride.

Правила размещения переменных в памяти для формата std140

Расположение в памяти переменных uniform-блока подчиняются довольно простым правилам. Все переменные располагаются последовательно именно в том порядке, в котором они перечислены в блоке. Каждая переменная из блока (включая структуры и их члены) характеризуются смещением внутри блока (offset) и выравниванием (alignment). Правила задаются рекурсивно в терминах структуры и ее полей. Весь uniform-блок рассматривается как одна структура, ее смещение равно нулю. Смещение первого члена структуры совпадает со смещением самой структуры. Смещение для следующего элемента определяется как смещение последнего байта предыдущего элемента, округленное вверх в соответствии с его выравниванием.

Ниже приводятся 10 правил, полностью определяющих расположение переменных в памяти для формата std140.

  1. Если переменная является скаляром, занимающим N байт памяти, то ее выравнивание равно N;
  2. Если переменная является 2-мерным или 4-мерным вектором, каждая компонента которого занимает N байт памяти, то то выравнивание для этой переменной будет 2*N или 4*N соответственно;
  3. Если переменная является 3-мерным вектором, каждая компонента которого занимает N байт, то ее выравнивание будет 4*N;
  4. Если переменная является массивом скаляров или вектором, то смещение и array stride определяются по правилам (1)-(3) и округляются до выравнивания типа vec4;
  5. Если переменная является column-major матрицей CxR, то она хранится как массив из C векторов по правилу (4);
  6. Если переменная является row-major матрицей CxR, то она хранится как массив из R векторов по правилу (4);
  7. Если переменная является массивом из S column-major матриц CxR, то она хранится как массив из S*C R-мерных векторов;
  8. Если переменная является массивом из S row-major матриц CxR, то она хранится как массив из S*R C-мерных векторов;
  9. Если переменная является структурой, то выравнивание для структуру равно наибольшему из выравниваний всех ее членов, округленному до выравнивания типа vec4, смещения и выравнивания для полей структуры определяются правилами (1)-(9). Структура может в конце быть дополнена байтами в соответствии со своим выравниванием;
  10. Если переменная является массивом из S структур, то все S элементов располагаются в соответствии с правилом (9).

Рассмотрим выравнивание и расположение в памяти ряда переменных:

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

Рис 1. Примеры размещения в памяти переменных разных типов.

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

Работа с uniform-блоком

Переменные, размещенные внутри именованного блока, принципиально отличаются от обычных uniform-переменных (размещенных в неименованном блоке). Если для установки значения переменным из неименованного блока используются функции glUniform* и положение (location) переменной (получаемое по ее имени), то для переменных из именованного uniform-блока такой способ не работает. Значения задаются сразу для всех переменных блока через соответствующий буфер.

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

GLuint glGetUniformBlockIndex ( GLuint program, const char * blockName );

Для получения информации по uniform-блоку по его индексу служит функция glGetActiveUniformBlockiv:

void glGetActiveUniformBlockiv ( GLuint program, GLuint blockIndex, GLenum pname, int * params );

Параметры program и blockIndex задают программу и uniform-блок из этой программы, параметр params указывает куда должны быть записаны результаты запроса, а параметр pname задает какую именно информацию необходимо вернуть (см. таблицу 1).

Таблица 1. Допустимые значение параметра pname для функции glGetActiveUniformBlockiv.

Название Комментарий
GL_UNIFORM_BLOCK_BINDING Возвращает точку привязки VBO к uniform-буферу
GL_UNIFORM_BLOCK_DATA_SIZE Возвращает размер блока в единицах соответствующего типа
GL_UNIFORM_BLOCK_NAME_LENGTH Возвращает длину имени блока с учетом '\0'
GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS Возвращает число активных uniform-переменных в блоке
GL_UNIFORM_BLOCK_ACTIVE_UNIFORM_INDICES Возвращает массив индексов активных переменных блока
GL_UNIFORM_BLOCK_REFERENCED_BY_VERTEX_SHADER Возвращается ненулевое значение, если данный блок используется вершинным шейдером
GL_UNIFORM_BLOCK_REFERENCED_BY_FRAGMENT_SHADER Возвращается ненулевое значение, если данный блок используется фрагментным шейдером
GL_UNIFORM_BLOCK_REFERENCED_BY_GEOMETRY_SHADER Возвращается ненулевое значение, если данный блок используется геометрическим шейдером

Следующий пример демонстрирует получение индекса блока и его размера.

GLuint	blockIndex = glGetUniformBlockIndex ( program, "MyBlock" );
int		size;
int		numUniforms;

glGetActiveUniformBlockiv ( program, blockIndex, GL_UNIFORM_BLOCK_DATA_SIZE,       &size );
glGetActiveUniformBlockiv ( program, blockIndex, GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS, &numUniforms );

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

void glGetUniformIndices ( GLuint program, GLsizei count, const char ** uniformNames, GLuint * uniformIndices );

Данная функция позволяет получить информацию по целой группе переменных, причем возможно даже из разных uniform-блоков. Параметр count задает количество переменных, для которых необходимо вернуть индексы, в массиве uniformNames содержится count имен переменных, а массив uniformIndices должен содержать достаточно места для размещения полученных индексов. В результате вызова в массив uniformIndices будут записаны индексы для заданных имен переменных, GL_INVALID_INDEX записывается в том случае, когда переданное имя не является именем переменной из какого-либо uniform-блока.

Для получения полной информации о группе переменных из uniform-блока по массиву индексов используется функция glGetActiveUniformsiv.

void glGetActiveUniformsiv ( GLuint program, GLsizei uniformCount, const GLuint * uniformIndices, GLenum pname, int * params );

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

Таблица 2. Допустимые значение параметра pname для функции glGetActiveUniformsiv.

Название Комментарий
GL_UNIFORM_TYPE Возвращает тип переменной. Допустимые значения - GL_FLOAT, GL_FLOAT_VEC2, GL_FLOAT_VEC3, GL_FLOAT_VEC4, GL_INT, GL_INT_VEC2, GL_INT_VEC3, GL_INT_VEC4, GL_UNSIGNED_INT, GL_UNSIGNED_INT_VEC2_EXT, GL_UNSIGNED_INT_VEC3_EXT, GL_UNSIGNED_INT_VEC4_EXT, GL_BOOL, GL_BOOL_VEC2, GL_BOOL_VEC3, GL_BOOL_VEC4, GL_FLOAT_MAT2, GL_FLOAT_MAT3, GL_FLOAT_MAT4, GL_FLOAT_MAT2x3, GL_FLOAT_MAT2x4, GL_FLOAT_MAT3x2, GL_FLOAT_MAT3x4, GL_FLOAT_MAT4x2 и GL_FLOAT_MAT4x3
GL_UNIFORM_SIZE Возвращает размер соответствующей переменной в байтах
GL_UNIFORM_NAME_LENGTH Возвращает длину имени переменной с учетом '\0'
GL_UNIFORM_BLOCK_INDEX Возвращает индекс uniform-блока, содержащего данную переменную
GL_UNIFORM_OFFSET Возвращает смещение переменной от начала блока
GL_UNIFORM_ARRAY_STRIDE Возвращается array stride, если переменная является массивом
GL_UNIFORM_MATRIX_STRIDE Возвращается matrix stride, если переменная является матрицей
GL_UNIFORM_IS_ROW_MAJOR Возвращается ненулевое значение, если переменная является row-major матрицей

В следующем примере получается необходимая информация о переменных uniform-блока.

const char * names [NUM] = 
{
	"diffColor",
	"specColor",
	"specPower",
	"emission"
};

GLuint	index [NUM];			// index for every variable
int		offset [NUM], size [NUM];

glGetUniformIndices   ( program, NUM, names, index );
glGetActiveUniformsiv ( program, NUM, index, GL_UNIFORM_OFFSET, offset );
glGetActiveUniformsiv ( program, NUM, index, GL_UNIFORM_SIZE,   size );

Работа с UBO

Значения для переменных uniform-блока берутся из VBO с типом GL_UNIFORM_BUFFER (UBO). В отличии от "привязки" (bind) для обычных буферов, когда буфер привязывается к конкретному типу, для UBO (также как и для буферов, используемых при transform feedback) используется так называемая индексированная привязка - сразу к типу (target) и целочисленному индексу.

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

void glBindBufferRange ( GLenum target, GLuint index, GLuint buffer, GLintptr offset, GLsizeptr size );
void glBindBufferBase  ( GLenum target, GLuint index, GLuint buffer );

Параметр target принимает значение GL_UNIFORM_BUFFER, параметр buffer задает используемый VBO, а параметр indexзадает точку привязки буфера. По этой точке привязки осуществляется связывание uniform-блока и буфера. Для этого используется функция glUniformBlockBinding.

void glUniformBlockBinding ( GLuint program, GLuint uniformBlockIndex, GLuint bindingIndex );

Здесь параметр bindingIndex задает точку привязки (из команды glBindBufferBase/glBindBufferRange) вершинного буфера к unifrom-блоку uniformBlockIndex.

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

-- vertex

#version 330 core

uniform mat4 proj;
uniform mat4 mv;
uniform mat3 nm;
uniform vec3 eye;       // eye position
uniform vec3 light;

in  vec3  pos;          // position in xyz
in  vec3  normal;
out vec3  n;
out vec3  v;
out vec3  l;
out vec3  h;

void main(void)
{
    vec4    p = mv * vec4 ( pos, 1.0 );
    
    gl_Position  = proj * p;
    n            = normalize ( nm * normal );
    v            = normalize ( eye - p.xyz );                   // vector to the eye
    l            = normalize ( light - p.xyz );
    h            = normalize ( l + v ); 
}

-- fragment

#version 330 core

uniform Lighting
{
    vec4    diffColor;
    vec4    specColor;
    float   specPower;
};

in  vec3 n;
in  vec3 v;
in  vec3 l;
in  vec3 h;

out vec4 color;

void main(void)
{
    vec3    n2   = normalize ( n );
    vec3    l2   = normalize ( l );
    vec3    h2   = normalize ( h );
    vec4    diff = diffColor * max ( dot ( n2, l2 ), 0.1 );
    vec4    spec = specColor * pow ( max ( dot ( n2, h2 ), 0.0 ), specPower );

    color = diff + spec;
}

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

void    setupUbo ( VertexBuffer& buf, int bindingPoint, const vec4 dColor, const vec4 sColor, float specPwr )
{
    static const char * names [3] =
    {
        "diffColor",
        "specColor",
        "specPower" 
    };
    
    static  GLuint  index  [3];         // index for every variable
    static  int     offset [3];
    static  GLuint  blockIndex;
    static  int     blockSize;
    static  bool    inited = false;
    
    if ( !inited )
    {
        inited     = true;
        blockIndex = program.indexForUniformBlock ( "Lighting" );
        blockSize  = program.uniformBlockSize ( blockIndex );
        glGetUniformIndices   ( program.getProgram (), 3, names, index );
        glGetActiveUniformsiv ( program.getProgram (), 3, index, GL_UNIFORM_OFFSET, offset );
        
                                        // init with zero's
        byte  * buffer = new byte [blockSize];
        
        memset ( buffer, 0, blockSize );
        
        buf.create   ();
        buf.bindBase ( GL_UNIFORM_BUFFER, bindingPoint );
        buf.setData  ( blockSize, buffer, GL_STREAM_DRAW );
        
        delete buffer;
    }
    
    buf.bindBase ( GL_UNIFORM_BUFFER, bindingPoint );
    program.bindBufferToIndex ( blockIndex, bindingPoint );

    byte  * ptr = (byte *) buf.map ( GL_WRITE_ONLY );
    
    memcpy ( ptr + offset [0], &dColor.x, 16 );
    memcpy ( ptr + offset [1], &sColor.x, 16 );
    memcpy ( ptr + offset [2], &specPwr,  4  );
    
    buf.unmap ();
}

По этой ссылке можно скачать весь исходный код к этой статье. Также доступны для скачивания откомпилированные версии для M$ Windows и Linux.