пятница, 4 ноября 2011 г.

Машина состояний OpenGL. Часть первая, знакомство.

"OpenGL – это машина состояния. Вы задаете различные переменные состояния, и они остаются в действии, сохраняя свое состояние, до тех пор, пока вы же их не измените."

Примерно такое определение можно найти  в первых главах любого учебника по OpenGL.
Если открыть любую публикацию, посвященную повышению производительности графики в OpenGL, к примеру любую из этих:
http://www.cs.virginia.edu/~gfx/Courses/2004/RealTime/Performance-Optimisation.pdf
http://developer.amd.com/media/gpu_assets/PerformanceTuning.pdf
http://developer.amd.com/media/gpu_assets/KRI%202006-OpenGL%20optimizations.pdf
то можно найти описание одной из самых популярных проблем падения производительности - "too many state changes", что дословно переводится как "слишком много переключений состояний", тех самых состояний, на каких основана работа всего OpenGL.

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


Давайте для начала разберемся откуда берутся эти лишние состояния. Попробуем вывести простой текстурированный примитив средствами GLScene:

[Image]

Cлева результат рендеринга через DirectOpenGL+VBO, справа - стандартными средствами GLScene. Как видите, на один кадр рендера приходится 111/397 вызов команд OpenGL, из них - 87/331 команд (78-86 процентов от общего числа команд) это установка различных состояний OpenGL (State Change Functions). Вот об этих самых состояниях нам и твердили, советуя уменьшать количество их переключений.

 Теперь разберемся откуда берется такое количество переключений состояний, для этого попробуем написать простенький класс рендера кубика, получим что-то по типу такого:
TMyCube = class
private
  FMaterial: TMaterial;
  ...
  procedure ApplyMaterial;
  procedure DrawCube;

public
  ...
  procedure RenderObject;
  property  Material: TMaterial read FMaterial write SetMaterial;
end;

Описываем метод ApplyMaterial:
procedure TMyCube.ApplyMaterial;
begin
  with FMaterial.FrontProperties do begin
    glMaterialfv(GL_FRONT, GL_AMBIENT, AmbientColor.ColorAsAddress);
    glMaterialfv(GL_FRONT, GL_DIFFUSE, DiffuseColor.ColorAsAddress);
    glMaterialfv(GL_FRONT, GL_SPECULAR, SpecularColor.ColorAsAddress);
    glMaterialfv(GL_FRONT, GL_EMISSION, EmissionColor.ColorAsAddress);
    glMateriali (GL_FRONT, GL_SHININESS, Shininess);
  end;
  with FMaterial.BackProperties do begin
    glMaterialfv(GL_BACK, GL_AMBIENT, AmbientColor);
    glMaterialfv(GL_BACK, GL_DIFFUSE, DiffuseColor);
    glMaterialfv(GL_BACK, GL_SPECULAR, SpecularColor);
    glMaterialfv(GL_BACK, GL_EMISSION, EmissionColor);
    glMateriali (GL_BACK, GL_SHININESS, Shininess);
  end;
end;
Ну и реализуем нашу процедуру рендеринга куба:
procedure TMyCube.RenderObject;
begin
  ApplyMaterial;
  DrawCube;
end;
Для экономии места я не стал расписывать метод DrawCube, так как он нас не интересует, так же нужно понимать что пример тривиальный, в реальных условиях, кроме материала, нам необходимо установить еще сотни других свойств перед началом рисования, что вы могли видеть на скриншоте выше.

Каждая команда glMaterialfv - это и есть наша установка состояний, таким образом, банальное применение материала приведет минимум к 10 установкам состояний.
Идеологически это правильный подход (хоть и устаревший), в идеале, после завершения рисования, мы должны были бы еще и сбросить все настройки материалов на стандартные, тоесть выполнить еще 10 дополнительных переключений состояний, но для экономии места я это так же пропущу, просто помните что этих переключений состояний очень много :)

Теперь представьте себе такую ситуацию, вы хотите сделать Minecraft, и вам для этого нужно вывести сотню таких кубиков, вы не задумываясь пишете:
for i:=-5 to 5 do for j:=-5 to 5 do begin
  glPushMatrix;  glTranslatef(i,0,j);
  MyCube.RenderObject;
  glPopMatrix;
end;
Не сложно подсчитать, что при таком подходе применение материала приведет к установке 1210 состояний (121 объект х 10 переключений на материал). Правда много? Теперь представьте что их у нас 331 штука на объект, а объектов несколько тысяч, а то и десятков тысяч (а кто-то хочет и 100000 вывести, чтоб на Minecraft было похоже).

Это серьезная проблема, так что же делать, как уменьшить это количество переключений, о котором нам все твердят? Помните первые строки этой статьи? Напомню:
"Вы задаете различные переменные состояния, и они остаются в действии, сохраняя свое состояние, до тех пор, пока вы же их не измените."
По русски это звучит так - нам незачем второй раз устанавливать одно и то же значение состояния.
Действительно, представьте себе что у нас все кубики одинаковые, тогда нам было бы достаточно лишь один раз установить материал и просто рисовать нашу сотню кубиков, тогда вместо 1210 переключений у нас было бы всего 10.

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

Но что делать если мы не можем отсортировать наши объекты (сложно, несколько условий сортировки, невозможность поменять порядок отрисовки и т.д.)? Тогда нужно выжимать все ресурсы из того что имеем. Я не даром привел полную версию установки свойств материала, если мы посмотрим внимательно на код, то увидим что в первой части процедуры мы устанавливаем настройки материала для лицевых граней, во второй части - для задних. Но ведь в кубике мы не видим задние/внутренние грани! И в большинстве случаев мы вообще не меняем эти свойства, так как обычно работает режим отсечения задних граней. Таким образом выходит что мы 121 раз устанавливаем одни и те же значения подряд, даже без сортировки! Внимательный читатель мог бы добавить, что даже среди оставшихся свойств материала далеко не все меняются от объекта к объекту, к примеру AmbientColor. И таких свойств в реальном цикле рендера огромное количество.
Если вы еще раз посмотрите на приведенные выше скриншоты, то увидите там еще одну строчку: "Effective State Change Functions", что дословно означает - "количество эффективных переключений". Это как раз, о чем мы только что говорили, под "эффективными" переключениями подразумеваются такие, которые действительно меняют значение состояния. В примере выше, таких переключений было 21% для случая с прямым вызовом команд ОГЛ и лишь 15% для обычного рендера сцены.

Естественное желание избавиться от такой лишней работы, но как?

Для любого программиста это не вопрос - достаточно лишь проверить какое состояние сейчас установлено, и если оно отличается от нужного - установить новое значение. К сожалению в OpenGL все немного сложнее, мы не можем сходу узнать что там установлено у OpenGL, для этого нам нужно запросить это состояние у OpenGL посредством команд "glGet*". Посмотрите еще раз на привденные выше скриншоты, видите в первой строке напротив "Get Functions" стоит желтый предупреждающий знак? Наверное это неспроста :)

Проблема в том, что после каждого вызова команды начинающейся на "glGet/glSet" происходит принудительная синхронизация CPU и GPU, другими словами, работа основного потока программы приостанавливается в ожидании когда GPU опустошит очередь команд, дойдя до нашего "glGet*", после чего драйвер вернет нам запрашиваемое значение и работа потока возобновляется, но теперь уже видеокарта простаивает, в ожидании пока приложение пришлет новые команды на рисование.  Если вставлять эти команды прямо в очередь рендера, то мы получим катастрофическое падение производительности из-за постоянной синхронизации (до начала цикла рендера и по завершению рендера можно смело их использовать).

Что же нам советуют умные дядьки - "кешируйте товарищи, кешируйте!". Другими словами, производители видеокарт предлагают нам создать в оперативной памяти полную копию всех состояний OpenGL (коих сотни) и при установке любого состояния дублировать его в оперативной памяти. Таким образом, когда нам потребуется узнать что сейчас установлено в OpenGL, нам будет достаточно прочитать это значение из системной памяти, что происходит мгновенно и без всяких синхронизаций. Хотя все же остается загадкой, почему производители видеокарт, разработчики драйверов и спецификаций уже порядка двадцати лет нам рекомендуют это сделать, но сами до сих пор не сделали это на уровне драйверов...

Если сортировка/группировка объектов доступна лишь в небольшом количестве ситуаций (часто для этого используются прокси и инстансы, но об этом в другой статье), то кеширование это просто обязательное условие для любого движка, и GLScene не исключение.
К сожалению ситуация с кешированием в сцене очень плачевная, что вы могли наблюдать на картинках выше, 15% попадание в кэш это катастрофа. И именно с этой проблемой я сходу столкнулся когда создал VBOMesh, так как в первых версиях везде использовались объекты сцены и всем кешированием занималась сцена.

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

1 комментарий:

  1. Такое замечание, правильно писать MineCraft от слова шахта.

    А так круто, полезно, да ещё и с картинками. Продолжай писать.

    ОтветитьУдалить