OpenGL communityСтатьи

Многопоточная работа с OpenGL

Автор:

В этой статье я расскажу об особенностях работы с OpenGL в нескольких потоках.

Введение
Создание общего контекста
Поведение OpenGL объектов в общих контекстах
Написание враппера
  Операции
  Глобальные переменные
  Окно
  Ядро
  Менеджер VAO
  Шейдеры
Загрузка ресурсов в параллельном потоке
Рендеринг в параллельном потоке
Тесты
  Время выполнения функций OpenGL
  Параллельный рендеринг
Заключение
Исходники

Введение

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



Создание общего контекста

Для ОС Windows есть два способа:

  • первый способ подходит для любых версий OpenGL
  • HDC   dc       = ...;
    HGLRC share_rc = ...; // ранее созданный контекст
    
    HGLRC rc = wglCreateContext( dc );
    wglShareLists( share_rc, rc );


  • второй способ использует расширение WGL_ARB_create_context
  • HDC   dc        = ...;
    HGLRC share_rc  = ...; // ранее созданный контекст
    int   attribs[] = ...; // атрибуты контекста
    
    HGLRC rc = wglCreateContextAttribsARB( dc, share_rc, attribs );


    Желательно чтобы у контекстов были одинаковые атрибуты и пиксельный формат.

    Для Unix подобных ОС есть функции glXCreateContext и glXCreateContextAttribsARB.


    Поведение OpenGL объектов в общих контекстах

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

      Framebuffer
    Создавать FBO можно в любом контексте, но присоединять текстуры и проверять статус нужно в том контексте в котором он будет использоваться.

      VertexArray
    Вот здесь и начинаются проблемы: VAO не делается общим для контекстов. При использовании идентификатора VAO созданного в другом контексте генерируется ошибка GL_INVALID_OPERATION или, если совпали идентификаторы с VAO, созданным в текущем контексте, то будут использованы его атрибуты.

    Все это проверялось на нескольких видеокартах от nVidia и ATI/AMD.



    Написание враппера

    Исходник враппера и примеров можно скачать по ссылке в конце статьи.
    Враппер предназначен для работы с OpenGL версии 3.3 и выше.

    Задачи:

  • инициализация OpenGL
  • создание окна
  • управление потоками
  • передача операций заданным потокам
  • упрощение работы с объектами OpenGL


  • Операции

    Операции предназначены для выполнения различных действий в потоках.

    class IOperation
    {
    public:
      virtual void Do() = 0;
      virtual void Free() = 0;
    };
    
    class IOperationC : public IOperation
    {
    public:
      virtual void SetCounter(int) = 0;
    };

    Интерфейс IOperation предназначен для выполнения операции в одном потоке, интерфейс IOperationC может выполняться в нескольких потоках.
    Функция Do() выполняет операцию, после чего обращений к этой операции не будет, поэтому в функции необходимо освободить все данные.
    Функция Free() предназначена для освобождения данных, если не удалось добавить операцию в очередь потока.
    Функция SetCounter(int) передает количество потоков в которые добавлена эта операция, при вызове функции Do() необходимо уменьшать счетчик на 1, а когда счетчик будет равен 0 - освободить данные.

    Пример простой реализации операций:

    class COperation : public IOperation
    {
    public:
      void Do()
      {
        // здесь можно что-нибудь выполнить
        
        delete this;
      }
      
      void Free()
      {
        delete this;
      }
    };
    
    class COperationC : public IOperationC
    {
    private:
      int  counter;
    
    public:
      void Do()
      {
        // здесь можно что-нибудь выполнить
        
        if ( --counter == 0 )
          delete this;
      }
    
      void Free()
      {
        delete this;
      }
      
      void SetCounter(int c)
      {
        counter = c;
      }
    };

    Так как изменение счетчика происходят в разных потоках, то их необходимо выполнять атомарно.


    Глобальные переменные

    Храниться глобальные переменные будут в статичных полях базового класса, от которого будут наследоваться все классы враппера.
    Такие переменные как контекст OpenGL и окно являются уникальными для каждого потока, поэтому их объявим как локальные переменные потока.

    Для объявление локальной переменной потока есть различные спецификаторы:

    __thread                // для GCC
    __declspec( thread )    // для Microsoft Visual C++
    thread_local            // если поддерживается стандарт C++11

    Объявим макрос THREAD_LOCAL который в зависимости от компилятора будет подставлять нужный спецификатор.

    Базовый класс

    class CBaseObject
    {
    protected:
      static THREAD_LOCAL GLContext const * _s_pContext;
      static THREAD_LOCAL CWindow         * _s_pWindow;
    
      static CVertexArraysManager         * _s_pVAOMan;
      static CCore                        * _s_pCore;
    
    public:
      static GLContext const *        s_GetGLContext()    { return _s_pContext; }
      static CVertexArraysManager *   s_GetVAOManager()   { return _s_pVAOMan; }
      static CWindow  *               s_GetWindow()       { return _s_pWindow; }
      static CCore    *               s_GetCore()         { return _s_pCore; }
    };

    Окно

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

    struct GLContext
    {
      // функции
      PFNGLACTIVETEXTUREPROC  __glActiveTexture;
      ...
      // константы
      bool                    __GL_ARB_texture_non_power_of_two;
      ...
      GLContext();
      
      // получить адреса функций и проверить поддерживаемые расширения
      bool Init();
    };

    Для вызова функций и обращений к константам служат макросы:

    // вызывает статичный метод класса CBaseObject
    // для получения указателя на текущий контекст
    #define GL_CALL( _func )                      s_GetGLContext()->_func
    
    // функции
    #define glActiveTexture                       GL_CALL( __glActiveTexture )
    ...
    // константы
    #define GLCONST_ARB_texture_non_power_of_two  GL_CALL( __GL_ARB_texture_non_power_of_two )

    Для перенаправления вызовов служит локальная переменная потока _s_pContext объявленная в класса CBaseObject.
    Указатель на структуру GLContext устанавливается тогда же когда происходит установка контекста рендеринга:

    // сделать контекст текущим
    void CWindow::MakeContextCurrent() const
    {
      _s_pContext = &_sGLContext;
    
      if ( ::wglGetCurrentContext() != _hRC )
        ::wglMakeCurrent( _hDC, _hRC );
    }
    
    // сбросить контекст рендеринга
    void CWindow::ResetContext() const
    {
      ::wglMakeCurrent( _hDC, NULL );
      _s_pContext = NULL;
    }


    Используется два класса окна: CMainWindow и CSharedWindow.
    CMainWindow - главное окно в которое идет рендеринг.
    CSharedWindow - невидимое окно для общего контекста OpenGL, работает в отдельном потоке.

    Типы окон:
    MAIN - главное окно
    LOAD - поток загрузки ресурсов
    RENDER - поток рендеринга

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


    Ядро

    Ядро инициализирует окно и потоки, управляет добавлением операций в потоки.

    Класс ядра выглядит так:

    class CCore : public CBaseObject
    {
    public:
      bool Init(const TWinDescriptor &sDescr, uint uRenderThreads, uint uLoadThreads, IOperation *pOp);
      bool Exit();
    
      bool PushOp(IOperation *pOp, e_window::type eType, bool bExceptCurrent = true);
      bool PushOpToAll(IOperationC *pOp, bool bExceptCurrent = true);
      bool PushOpToAll(IOperationC *pOp, e_window::type eType, bool bExceptCurrent = true);
    };

    Функция Init создает главное окно и (uRenderThreads + uLoadThreads) потоков с общими контекстами, добавляет операцию pOp в очередь главного окна и запускает цикл.
    Функция Exit завершает все потоки созданные потоки и закрывает главное окно.
    Функция PushOp добавляет операцию в поток заданного типа с самой короткой очередью операций, параметр bExceptCurrent определяет пропускать ли при поиске потоков текущий поток.
    Функции PushOpToAll добавляет операции во все потоки удовлетворяющие условиям (типу и пропуску текущего потока).



    Менеджер VAO

    Его задачей является создание и настройка VAO для всех контекстов и удаление всех созданных VAO при выходе из приложения. Для этого используется операции 2 типа операций:

    Операция создания и настройки VAO

  • выполняется для всех потоков.
  • создает VAO, привязывает к нему буферы и устанавливает атрибуты.



  • Операция удаления всех VAO
  • выполняется для всех потоков.
  • в главном потоке идет ожидание пока все операции не выполнится.



  • Шейдеры

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

    class CShaderProgram : public CBaseObject
    {
    protected:
      // идентификаторы программ для каждого контекста
      std::vector< GLuint >  _aIdents;
    
    public:
      // получить идентификатор программы для текущего контекста
      GLuint  Id() const  { return _aIdents[ s_GetWindow()->Index() ]; }
    };

    Метод CWindow::Index() возвращает уникальный индекс потока.



    Загрузка ресурсов в параллельном потоке

    Алгоритм:

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



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



  • Решение:
  • временный объект должен быть объектом того же класса что и загружаемый.
  • после загрузки будет заменяться не указатели, а данные объекта (например: идентификаторы текстур).



  • Рендеринг в параллельном потоке

    Алгоритм (вариант1):

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



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


    Алгоритм (вариант2):
  • инициализация происходит во всех потоках рендеринга, в каждом потоке инициализируется те ресурсы, которые будут в нем использованы.
  • на один поток создается по две текстуры для рендеринга.
  • в главном потоке используется первая текстура из параллельного потока, тогда как в параллельном потоке идет рендеринг во вторую текстуру.
  • когда текстура в параллельном потоке готова добавляется операция, которая меняет местами текстуры.



  • Тесты


    Время выполнения функций OpenGL

    Здесь показаны замеры времени выполнения функций создания и загрузки ресурсов OpenGL. Замеры производились с помощью QueryObject. Результаты показывают выигрыш во времени при асинхронной загрузке ресурсов.

    Загрузка текстуры
    BGR текстура разрешением 1024х1024, размер 3Мб
    glBindTexture + glTexImage2D - 3.8мс
    glGenerateMipmap - 5.3мс
    При создании текстуры дольше всего выполнялась операция glBindTexture( GL_TEXTURE_2D, 0 ) после передачи данных, поэтому замерялось время выполнения нескольких функций.

    Загрузка данных в буфер
    размер 3Мб
    glBufferData - 4.3мс

    Компиляция шейдера и линковка программы
    простые шейдеры из примеров к статье
    glShaderSource + glCompileShader  - 784нс
    glLinkProgram - 800нс


    Параллельный рендеринг

    В конце статьи можно скачать исходники и бинарники тестов. Настройки с которыми проводились тесты находятся в файле config2.txt.

    Тест1
    В этом тесте есть возможность задавать произвольное количество параллельных потоков рендеринга. Каждый поток рисует часть изображения, которое потом выводится на экран.
    Результаты (кадры в секунду):
    1 поток:  50.7
    2 потока:  49.9
    3 потока:  49.1
    4 потока:  48.3
    5 потоков: 45.7
    6 потоков: 27.8

    Графики времени работы потоков (в миллисекундах):
    Графики времени рендеринга | Многопоточная работа с OpenGL

    Из-за сильной нагрузки на видеокарту производительность лучше при использовании одного потока. С увеличением количества потоков возрастают потери на синхронизацию и рендеринг в текстуру.


    Тест2
    Тест работает в одном или двух потоках, сначала идет рендеринг в две текстуры, потом они выводятся на экран.

    Первый режим (в конфиг файле: mode = 0) - рисуется два фрактала.
    Результаты (кадры в секунду):
    1 поток:  42.8
    2 потока: 42.9
    В данном случае распараллеливание не ведет к изменению производительности.

    Второй режим (в конфиг файле: mode = 3) - рисуется по 4096 торов на текстуру, в итоге в сумме получается почти 8.4 млн. треугольников.
    Результаты (кадры в секунду):
    1 поток:  42.3
    2 потока: 49.8 (+18%)
    По сравнению с предыдущими тестами здесь увеличилась нагрузка на ЦПУ, что позволило получить выигрыш от распараллеливания.



    Заключение

    Эффективность параллельного рендеринга зависит от отношения производительностей ЦПУ и ГПУ, реализации рендера (движка). Выигрыш получается когда в одном потоке не удается использовать видеокарту 100% времени, обычно это возникает из-за загруженности ЦПУ. В некоторых случаях можно получить снижение производительности из-за распараллеливания, но в большинстве случаев если и не получился прирост производительности, то нет и потерь.



    Исходники

    Скачать исходники враппера и примеров: Многопоточная работа с OpenGL (примеры)
    Скачать исходники обновленного враппера и тестов: Многопоточная работа с OpenGL (тесты)

    Часть исходников были взяты из уроков Артема Гуревича (aka KpeHDeJIb).
    Шейдер фрактала позаимствован отсюда.

    #OpenGL, #многопоточность

    17 ноября 2011 (Обновление: 24 ноя 2011)

    Комментарии [69]