Опыт изучения OpenGL — Часть 9 — Uniform Variables & Uniform Blocks

В прошлых заметках речь шла о пространственных преобразованиях и соответствующих матрицах. Преобразования производятся над вершинами 3-хмерного объекта (модели) путем умножения координат вершин на матрицы. Заниматься этим умножением вершин на матрицы должен видеопроцессор, то есть шейдеры, а именно vertex shader. Почему именно видеопроцессор? Потому что вершин очень много, а у видеопроцессора много ядер, поэтому он может выполнить преобразование большого количества вершин гораздо эффективнее, чем это сделает CPU. Когда производится конкретное пространственное преобразование над некоторой моделью, все вершины этой модели умножаются на одну и ту же матрицу. Вопрос: как передать эту матрицу видеопроцессору? Этот вопрос мы и рассмотрим в сегодняшней заметке.

Чтобы видеопроцессор отобразил на экране некую модель, нам надо скормить ему координаты вершин этой модели — это более или менее большой поток векторов. Vertex shader принимает входные данные. Эти данные бывают разных видов. Например координаты вершин — это т. н. атрибуты — vertex attributes. У каждой вершины свои значения атрибутов. А например матрица перспективной проекции — одна и та же для всех вершин, поэтому она вертексным атрибутом не является. Такие входные данные называются словом uniform — вероятно чтобы подчеркнуть, что эти данные одинаковые для всех вершин модели.

Для примера я решил добавить в программу рисования треугольника все необходимое для перехода от двумерной программы в трехмерную, т. е. мир, камеру, перспективную проекцию. С точки зрения шейдера для этого нужно всего ничего — передать шейдеру матрицу, которая будет произведением трех матриц: model-to-world-matrix, world-to-camera-matrix и perspective-projection-matrix. Она и будет нашей uniform’ой. Кроме того, я хочу, чтобы треугольник вращался (см. рис. 1). Для этого я буду изменять матрицу model-to-world-matrix (она будет по-сути являться матрицей поворота) со временем.

Рис. 1 — Вращающийся треугольник.

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

Uniform Variables

Это будет 1-й способ. Про uniform variables вы можете почитать например [Wolf, Chapter 1 — Getting Started with GLSL, Sending data to a shader using uniform variables] и [Angel, 3.12.2 Uniform Variables] или [Guide9th, Chapter 2 — Shader Fundamentals].

Итак, добавляем в наш написанный ранее простецкий вертексный шейдер uniform’ную переменную, которую я обозвал m4_model_world_camera_projection (такую матрицу также обозначают аббревиатурой MVP — Model View Projection). Это матрица 4×4, на которую мы умножаем позицию вершины:

/shaders/simple3d_uniform_matrix.vert

#version 400 core

layout (location = 0) in vec3 v3_position; // vertex position (a vertex attribute)
layout (location = 1) in vec4 v4_color;    // vertex color (a vertex attribute)

uniform mat4 m4_model_world_camera_projection; // MVP matrix (a uniform variable)

out vec4 vs_color;

void main(void)
{
    gl_Position =
        m4_model_world_camera_projection *
        vec4(v3_position, 1.0f);
                   
    vs_color = v4_color;
}

Чтобы передать в шейдерную программу значение uniform’ной переменной, нужно:

  1. Обозначить, к какой шейдерной программе вы обращаетесь (функция glUseProgram).
  2. Узнать числовой индекс, уникально идентифицирующий uniform’ную переменную внутри шейдерной программы. Узнать его можно при помощи функции glGetUniformLocation, которая принимает в качестве параметра текстовую строку — имя нашей uniform’ной переменной.
  3. Собственно, передать значение переменной при помощи одной из множества функций glUniform*. Функций glUniform* много. Каждая соответствует своему типу данных. Например функция glUniform1i соответствует целой переменной, а есть функция glUniform3fv — вектору из трех чисел с плавающей точкой.

Означенные действия для переменных, не являющихся векторами, я свел в одну шаблонную функцию setUniform<T>, которую поместил в класс opengl::Program. Шаблонный параметр этой функции — это тип uniform’ной переменной. Соответственно, у этой шаблонной функции много специализаций, каждая из которых вызывает одну из функций glUniform*.

namespace opengl { class Program
{
    ...

    public:
        // Assigns a scalar value of type T to a uniform variable
        template<typename T>
        void setUniform(
            const std::string& uniformName,
            T value)
        {
            setUniform<T>(getUniformLocation(uniformName), value);
        }

        // Assigns a scalar value of type T to a uniform variable.
        // This function is a template which has a number of specializations (see below).
        template<typename T>
        void setUniform(
            GLint uniformLocation,
            T value);

        template<>
        void setUniform<GLint>(
            GLint uniformLocation,
            GLint value)
        {
            Program::Using use(*this);
            glUniform1i(uniformLocation, value);
        }

        template<>
        void setUniform<GLfloat>(
            GLint uniformLocation,
            GLfloat value)
        {
            Program::Using use(*this);
            glUniform1f(uniformLocation, value);
        }

        template<>
        void setUniform<GLuint>(
            GLint uniformLocation,
            GLuint value)
        {
            Program::Using use(*this);
            glUniform1ui(uniformLocation, value);
        }
};}

Можно было бы добавить специализации той же шаблонной функции и для векторных типов данных, например, так:

#include "Vector.h"

namespace opengl { class Program
{
    ...
    public:
        template<>
        void setUniform< vmath::Vector<GLfloat, 3> >(
            GLint uniformLocation,
            const vmath::Vector<GLfloat, 3>& value)
        {
            Program::Using use(*this);
            glUniform3fv(
                /*location*/ uniformLocation,
                /*count*/ 1,
                /*value*/ value.data());
        }
};}

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

namespace opengl { class Program
{
    ...
    public:
        // Assigns a 3-dimensional vector to a uniform variable.
        void setUniform3fv(
            const std::string& uniformName,
            const GLfloat* value)
        {
            setUniform3fv(getUniformLocation(uniformName), value);
        }

        // Assigns a single matrix in column-major order to a uniform variable.
        void setUniformMatrix(
            GLuint uniformLocation,
            const GLfloat* matrix)
        {
            Program::Using use(*this);
            glUniformMatrix4fv(
                /*location*/ uniformLocation,
                /*count*/ 1,
                /*transpose*/ GL_FALSE,
                /*value*/ matrix);
        }
};}

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

// src/snippets/Program_uniform_variable.h

class Program final : public ProgramBase
{
private:
    ...
    GLuint m_UniformModelToWorldToCameraProjectionMatrix; // MVP matrix uniform variable location
    WorldPositionAndOrientation m_TrianglePositionAndOrientation;

public:
    Program() :
        m_Program(
            std::vector<opengl::Shader*>
            {
                &opengl::make_ShaderFromFile(opengl::Shader::SHADER_TYPE::VERTEX, "../../shaders/simple3d_uniform_matrix.vert"),
                ...
            }),
        ...
        m_TrianglePositionAndOrientation()
    {
        // Get the location of MVP matrix uniform variable.
        m_UniformModelToWorldToCameraProjectionMatrix = m_Program.getUniformLocation("m4_model_world_camera_projection");

        // Move the triangle 10 units forward so that it appears before the camera and we can see it.
        m_TrianglePositionAndOrientation.MoveForwardBackward(10);

        ...
    }

protected:
    void UpdateTime(double currentTime, double timeDelta) override
    {
        // Rotate the triangle.
        m_TrianglePositionAndOrientation.RollWorld(timeDelta);

        // Pass the new value of the MVP matrix to the shader program.
        m_Program.setUniformMatrix(
            /*uniformLocation*/ m_UniformModelToWorldToCameraProjectionMatrix,
            /*matrix*/ (getCamera().getPerspectiveProjectionMatrix() * getCamera().getWorldToCameraMatrix() * m_TrianglePositionAndOrientation.ModelToWorldMatrix()).data()
        );
    }

    ...
}

Как видите, все отличие заключается в том, что в методе UpdateTime вычисляется новое значение матрицы MVP, которое передается в шейдерную программу. В программе используется объект типа WorldPositionAndOrientation. Об этом типе я рассказывал в прошлой заметке — он хранит позицию и ориентацию нашей модели (в данном случае — треугольника) в мировом пространстве.

Uniform Blocks

Это 2-й способ передать в шейдерную программу uniform’ные данные. Про uniform blocks вы можете почитать в [Wolf, Chapter 1 — Getting Started with GLSL, Using uniform blocks and uniform buffer objects] и [McKesson, Chapter 7 — World in Motion, Shared Uniforms] или [Guide9th, Chapter 2 — Shader Fundamentals, Interface Blocks].

В чем коренные отличия uniform block от uniform variable?

  • Uniform block в общем случае объединяет несколько переменных.
  • Для хранения uniform block’а надо выделять специальный буфер — uniform block buffer.
  • Для чего собственно существуют uniform block’и. Пусть имеется несколько шейдерных программ с одинаковыми uniform’ными данными. Если вы используете uniform variable для передачи этих данных, то вы должны задавать значение каждой uniform’ной переменной отдельно для каждой шейдерной программы (при помощи функций glUniform*). А вот если вы используете uniform block, то это позволяет нескольким программам разделять (share) один и тот же буфер в памяти (uniform block buffer), а вам достаточно один раз задать значение, которое лежит в буфере.

С точки зрения синтаксиса GLSL uniform block отличается от uniform variable тем, что uniform block объединяет несколько переменных при помощи фигурных скобок. Вот вертексный шейдер, который делает то же самое, что и предыдущий, но только с использованием uniform block:

// /shaders/simple3d_one_matrix.vert

#version 400 core

layout (location = 0) in vec3 v3_position; // vertex position
layout (location = 1) in vec4 v4_color;    // vertex color

// Here is the uniform block with just one variable (matrix) inside
layout (std140) uniform TransformBlock
{
    mat4 m4_model_world_camera_projection;
};

out vec4 vs_color;

void main(void)
{
    gl_Position =
        m4_model_world_camera_projection *
        vec4(v3_position, 1.0f);
                   
    vs_color = v4_color;
}

С точки зрения API OpenGL отличие между uniform block и uniform variable следующее: для unform block’а нужно выделить специальный буфер — uniform block buffer, в котором будут храниться данные. Далее uniform block buffer привязывается (bind) к uniform block’у через так называемую точку привязки — binding point. В [McKesson, Chapter 7 — World in Motion, Shared Uniforms, Uniform Buffer Binding] есть картинка, которую я позволил себе перерисовать. Она иллюстрирует механизм этой привязки (рис. 2).

Рис. 2 — Связывание uniform block в шейдерной программе и буфера uniform block buffer через точку привязки binding point. Каждая точка привязки имеет числовой индекс; типичное максимальное количество доступных точек привязки — порядка сотни, узнать его можно при помощи функции glGetIntegerv.

В своем API я во-первых создал класс opengl::UniformBlockBuffer, наследующий классу opengl::Buffer. Этот класс очень простой, он добавляет к функциональности класса opengl::Buffer только одну функцию BindToUniformBindingPoint:

class UniformBlockBuffer : public Buffer
{
public:
    // Constructor
    UniformBlockBuffer(GLsizeiptr size, DRAW_USAGE usage, const void* data = NULL) :
        Buffer(size, TARGET::UNIFORM, usage, data)
    {}

    // Binds uniform block buffer to a specified binding point.
    void BindToUniformBindingPoint(GLuint index)
    {
        glBindBufferBase(GL_UNIFORM_BUFFER, index, getHandle());
    }
};

template<typename T>
class UniformBlockStructure : public UniformBlockBuffer
{
...
public:
    UniformBlockStructure(const T& data, DRAW_USAGE usage) :
        UniformBlockBuffer(sizeof(T), usage, &data)
    {}

    void setValue(const T& value)
    {
        setData(
            /*offset*/ 0,
            /*size*/ sizeof(T),
            /*buffer*/ &value
        );
    }
}

Класс UniformBlockBuffer, как и класс Buffer, работает с сырыми нетипизированными данными. Класс UniformBlockStructure — это шаблонный класс, который наследует классу UniformBlockBuffer и добавляет в него типизацию. В общем-то, из кода все должно быть очевидно.

Во-вторых, я добавил в класс opengl::Program метод, который привязывает uniform block с заданным именем к определенной точке привязки:

namespace opengl { class Program
{
...
public:
    void setUniformBlockBinding(
        const std::string& uniformBlockName,
        GLuint uniformBlockBindingPoint)
    {
        glUniformBlockBinding(
            m_Handle,
            getUniformBlockIndex(uniformBlockName),
            uniformBlockBindingPoint);
    }
...
};}

std140

По-умолчанию OpenGL оставляет за драйвером видеокарты право определять то, как размещаются отдельные uniform’ные переменные внутри uniform block’а, т. е. по каким смещениям они находятся в блоке. Драйвер, очевидно, может компоновать по-разному переменные в блоке исходя из соображений, например, экономии памяти. Соответственно программист должен знать смещения переменных внутри блока, чтобы помещать данные в правильные места буфера unform block buffer. Программист может запросить смещение каждой переменной в блоке при помощи двух функций: glGetUniformIndices() и glGetActiveUniformsiv(), однако это, прямо скажем, геморрой. Дело бы существенно упростилось, если бы драйвер видеокарты при размещении переменных внутри блока действовал точно так же как компилятор при размещении полей внутри структуры. Именно это и делает layout qualifier std140, который пишется в объявлении uniform block’а в шейдере. Он определяет четкие правила, в соответствии с которыми данные будут размещены внутри буфера. Наличие квалификатора std140 дает программисту точное знание, по каким смещениям будут расположены те или иные данные в буфере без необходимости запрашивать смещения переменных во время выполнения программы.

Итак, вот та же программа с вращающимся треугольником, только написанная с использованием uniform block (как и ранее, привожу только куски кода, которые отличаются от предыдущей программы):

// src/snippets/Program_uniform_block.h

class Program final : public ProgramBase
{
private:
    ...
    WorldPositionAndOrientation m_TrianglePositionAndOrientation;
    opengl::UniformBlockStructure<mat4> m_MVPMatrixUniformBlockBuffer; // the buffer where MVP matrix is going to be stored
    const GLuint m_UniformBlockBindingPointForMVPMatrix = 0;           // the uniform binding point index

public:
    Program() :
        m_Program(
            std::vector<opengl::Shader*>
            {
                &opengl::make_ShaderFromFile(opengl::Shader::SHADER_TYPE::VERTEX, "../../shaders/simple3d_one_matrix.vert"),
                ...
            }),
        ...
        m_TrianglePositionAndOrientation(),
        m_MVPMatrixUniformBlockBuffer(vmath::make_IdentityMatrix4x4<GLfloat>(), opengl::Buffer::DRAW_USAGE::DYNAMIC_DRAW)
    {
        // Move the triangle 10 units forward so that it appears before the camera and we can see it.
        m_TrianglePositionAndOrientation.MoveForwardBackward(10);

        // Bind together the uniform block in the shader program and the uniform block buffer.
        m_Program.setUniformBlockBinding("TransformBlock", m_UniformBlockBindingPointForMVPMatrix);
        m_MVPMatrixUniformBlockBuffer.BindToUniformBindingPoint(m_UniformBlockBindingPointForMVPMatrix);
        ...
    }

protected:
    void UpdateTime(double currentTime, double timeDelta) override
    {
        // Rotate the triangle.
        m_TrianglePositionAndOrientation.RollWorld(timeDelta);

        // Store the new value of the MVP matrix in the uniform block buffer.
        m_MVPMatrixUniformBlockBuffer.setValue(
            getCamera().getPerspectiveProjectionMatrix() * getCamera().getWorldToCameraMatrix() * m_TrianglePositionAndOrientation.ModelToWorldMatrix());
    }

    ...
}

Кто быстрее

Интересно, может быть какой-то из двух рассмотренных вариантов передачи uniform’ных переменных быстрее? В топике SPEED: glUniform Vs. uniform buffer objects было сказано, что uniform block в общем случае быстрее. И более того, даже unform variable под капотом использует unform block buffer. Я подсчитал число кадров в секунду (frames per second, fps) для каждой из представленных выше программ с вращающимся треугольником, и получил следующее:

  • uniform variable
    fps = 275, через 20 секунд fps = 325
  • uniform block
    fps = 325 сразу

Структуры (struct) в GLSL

И последнее. Когда я вижу в шейдере объявление uniform block, у меня сразу возникает ассоциация со структурами (struct). Поэтому хочу пояснить разницу между этими двумя вещами. Сравним два шейдера. Первый шейдер будет с объявлением uniform блока:

// A shader with a uniform block

// This is a uniform block and it can be passed to the shader only by means of a uniform block buffer!
uniform LightInfo
{
    vec4 Position;
    vec3 Ambient;
    vec3 Diffuse;
    vec3 Specular;
};

void main()
{
    vec3 s = Position;
    ...
}

Обратите внимание: блок называется LightInfo, но при обращении к переменной Position мы не пишем LightInfo.Position. Мы пишем просто Position.
Теперь посмотрим шейдер с объявлением структуры LightInfo и unform’ной переменной Light типа LightInfo:

// A shader with a struct

struct LightInfo
{
    vec4 Position;
    vec3 Ambient;
    vec3 Diffuse;
    vec3 Specular;
};

uniform LightInfo Light;
/*
The above declaration is equivalent to the declaration of four separate uniform variables:
uniform vec4 Light.Position
uniform vec3 Light.Ambient;
uniform vec3 Light.Diffuse;
uniform vec3 Light.Specular;
*/


void main()
{
    vec3 s = Light.Position;
    ...
}

Как вы уже прочитали в комментарии, объявить unform’ную переменную, которая является структурой — это то же самое, что объявить в виде unform’ной переменной каждое поле этой структуры в отдельности. Такая структура не является unform блоком и не хранится в едином uniform block буфере. Каждое поле структуры — это отдельная uniform’ная переменная с отдельным индексом location, который можно получить, вызвав glGetUniformLocation(), например так: GLint uniformLocation = glGetUniformLocation(progHandle, "Light.Position");. А значения отдельных полей структуры передаются при помощи обычных функций glUniform*, например так: glUniform4fv(uniformLocation, 1, data);.

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

// A shader with a named uniform block

// This is a uniform block and it can be passed to the shader only by means of a uniform block buffer!
uniform LightInfo
{
    vec4 Position;
    vec3 Ambient;
    vec3 Diffuse;
    vec3 Specular;
} Light;

void main()
{
    vec3 s = Light.Position;
    ...
}

Резюме

Постепенно вырисовывается концепция отрисовки одного 3-хмерного объекта на экране. Для отрисовки нужны следующие компоненты (рис. 3):

  • Массив атрибутов вершин модели + способ их соединения/рисования (Mesh)
  • Шейдерная программа (Shader Program)
  • Позиция и ориентация модели в мировом пространстве (WorldPositionAndOrientation)
Рис. 3 — 3d-объект «в первом приближении».

Каждый из перечисленных компонентов необязательно принадлежит одному и только одному 3-хмерному объекту. Каждый из компонентов (за исключением пожалуй WorldPositionAndOrientation) может быть использован различными объектами. Например, два разных объекта могут использовать одну и ту же шейдерную программу или одну и ту же модель (mesh). Как видно на рисунке 3, отдельные компоненты соединены некими связями с шейдерной программой. Желательно эти связи как-то унифицировать, стандартизировать и т. д., чтобы 3d-объект можно было собирать из разных компонентов, как конструктор. Чем-то подобным я планирую заняться в следующих заметках.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *