Использование ядер графического процессора для разгрузки процессора с помощью GLSL

Вступление

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

Говоря простым языком, я пытаюсь заставить графический процессор создавать фактические графические модели из данных, не связанных с графикой, что, по-видимому, не столь распространенная задача OpenGL. Это можно рассматривать как промежуточный шаг между чистым рендерингом (OpenGL) и чистыми вычислениями (OpenCL). Мы переносим вычислительный процесс на GPU, но это все еще код, связанный с рендерингом.

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

Графические процессоры обладают огромной мощностью. Поскольку в настоящее время я работаю над менее требовательными 2D-приложениями, я стабилизировал свою разработку в старом стандарте OpenGL (3.3), используя старые и недорогие карты NVIDIA Quadro, которые были обычным явлением в старых торговых рабочих станциях. Я делаю это, чтобы мое приложение можно было использовать на устаревшем оборудовании. Карты, над которыми я работаю, насчитывают 16 ядер, что не впечатляет, но современные карты теперь имеют сотни ядер (на момент написания этой статьи Nvidia GeForce RTX 2080 Ti представляет собой ультрасовременную видеокарту с 4352 ядрами. ядра). Наличие более 4000 ядер в одном доступном по цене компьютере - это просто потрясающе. Однако даже скромные устаревшие карты значительно улучшают графическую производительность по сравнению с традиционным подходом, когда графика реализуется на уровне приложения с использованием ЦП. В качестве компромисса программирование GLSL и выгрузка графики на GPU усложняют программирование: отладка GLSL является сложной задачей, а иногда и разочаровывающей.

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

Данные вершин включают произвольные данные

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

В нашем примере мы заставим нашу карточку рисовать простой свечной график. Таким образом, наши входные данные будут такими:

open price, high price, low price, close price, volume, timestamp

Из этого мы хотим, чтобы GLSL нарисовал:

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

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

Типы данных GLSL

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

bool - boolean type (true/false)
float - 4 bytes
double - 8 bytes
int - 4 bytes signed integer
uint - 4 bytes unsigned integer
vec2 - 2 float vector (vec2.x, vec2.y)
vec3 - 3 float vector (vec3.x, vec3.y, vec3.z)
vec4 - 4 float vector (vec4.x, vec4.y, vec4.z, vec4.w)
ivec2 - 2 int vector (ivec2.x, ivec2.y)
ivec3 - 3 int vector (ivec3.x, ivec3.y, ivec3.z)
ivec4 - 4 int vector (ivec4.x, ivec4.y, ivec4.z, ivec4.w)

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

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

Исходный код (в Java, использующий JOGL, но аналогичный в C ++) создает единый массив со всей информацией и тремя VBO (объектами буфера вершин).

Этот массив будет использоваться для загрузки каждого буфера. Информация, которую нам нужно обработать, - это stride, а вершинный шейдер offset so OpenGL может позже найти данные. Конкретные используемые glVertexAttribPointers:

Обратите внимание, как мы используем glVertexAttribIPointer вместо glVertexAttribPointer, потому что мы имеем дело с целочисленными данными. По какой-то причине мне не удалось передать отрицательные целые числа, даже используя GL_INT вместо GL_UNSIGNED_INT.

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

Вершинные и геометрические шейдеры

Теперь вершинный шейдер может собирать все данные:

Для входных данных атрибут макета местоположения определяет, какой именно атрибут мы собираем, числа должны совпадать с числами, используемыми в glVertexAttribIPointer. Эта информация собирается из геометрического шейдера с использованием переменных gs_ohlc, gs_volume и gs_timestamp. Обратите внимание, как имена переменных в вершинном шейдере должны совпадать с именами переменных в шейдере геометрии. Также обратите внимание, что мы не используем gl_Position, потому что мы еще не имеем дело ни с какими позициями, а только с абстрактными данными высокого уровня. В этом нет необходимости, вы все равно можете передавать произвольные данные из Vertex в шейдер Geometry.

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

Мы строим наши примитивы (в данном случае line_strips) на основе данных OHLC и Timestamp. Пример грубый и не содержит всех дополнительных деталей, необходимых для построения всей диаграммы. Но он показывает, как можно перемещать абстрактные данные в графический процессор для реализации графических примитивов.

Резюме

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

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

Я все еще изучаю, могут ли все данные быть переданы в виде массива из 6 элементов, чтобы включить потенциально другие случаи, когда мы могли бы захотеть переместить большие фрагменты данных в графический процессор. Мне также нужно охватить такие вещи, как прокрутка, масштабирование и масштабирование. GLSL оказался столь же продуманным, как и увлекательной темой, которую можно изучить с нуля. Как упоминалось ранее, у меня все еще возникают проблемы с простыми вещами, такими как передача отрицательных целых чисел в вершинный шейдер. Это явный признак того, что GLSL не так интуитивно понятен, как другие технологии. Тот факт, что большая часть учебных материалов ориентирована на 3D и что я использую Java JOGL (в большинстве материалов используется C ++), также не облегчает этот процесс.

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