Войти
Totem 4 Engine BlogСтатьи

Totem Engine 4 Blog. Статья 2. Система рендера.

Автор:

Я думаю, что самая значительная часть любого игрового движка отвечает за отображение (render, рендер). Мой случай не является исключением. В одну статью понятно не уложить все описание рендера, я считаю что и не нужно.

Я буду описывать только самые значительные, на мой взгляд, вещи. Начну...

Введение.

Не могу сформулировать, что я считаю системой рендера, но точно знаю, что не считаю. То что называют forward render или deferred render или всяческие вариации на их тему – это не система рендера, это методы обработки графики в рамках системы рендера. Система рендера должна позволять реализовать любой метод обработки графики, в пределах возможностей, конечно.

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

Замечание 1: Далее по тексту под системой рендера будет подразумеваться описываемая мною реализация.

Замечание 2: В целом подход универсален для любого GAPI (graphics application programming interface), но так как я работаю только с D3D9, то его особенности будут порой видны.


Составляющие системы рендера (render system).

Система рендера содержит в себе все возможности по обработке объекта рендера. Объект рендера (render object) — это любая графическая сущность, которую пользователь хочет отобразить тем или иным способом.

Составные части:

-Основной вершинный буфер.
-Массив буферов вершин вспомогательных. Размер массива по константе системы рендера.
-Буфер индексов.
-Материал.
-Параметры, определяющие место и размер данных, которые использует объект из вершинных буферов.
-Параметры, определяющие место и размер данных, которые использует объект из индексного буфера.
-Тип и количество примитивов.
-Данные для инстансинга.
-Мировая позиция объекта.
-Границы объекта для отсечения по фрустуму.
-Временные данные рендера.

Материал рендера (render material) определяет тип отображения объекта рендера. В нем храняться параметры рендера, такие как эффекты, текстуры и прочее для каждого прохода рендера. Материал сложная структура, по сравнением с объектом — тут без картинки не разберешься.
Изображение

Материал хранит проходы. Каждый проход материала (render material pass) соответствует проходу рендера. Проверка осуществляется при создании материала. Если есть проход рендера, для которого нет прохода в материале, то оставляем в массиве NULL, чтобы сохранить идентичные размеры списков проходов для прямого обращения по номеру, во избежание поиска.

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

Система связи данных представлена в проходах материала. При создании прохода нам нужно позаботиться о данных для шейдеров. В файле материала при помощи специальных директив указывается:

-Имя переменной в шейдере.
-Имя переменной в движке.
-Тип переменной в движке.
-Место нахождения перменной: материал, проход материала, переменная рендера (постоянная), текущая переменная рендера (скажем матрица текушего объекта).

Все эти данные обрабатываются (происходит поиск источников данных), чтобы создать тип данных TE4RMFloatConstantData. Задача этого класса - связывать два адреса в памяти (источник и место назначения). Массив m_stdvConstantsData всех возможных констант эффектов создается на этапе инициализации. При установке активного эффекта прохода (для материала, не для рендера) из этого списка берутся только константы текущего эффекта.

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

Каждый источник имеет версию. Эта переменная служит для сравнения версии источника и версии в месте назначения (эффект или буфер инстансинга). В случае несовпадения версий переменная в шейдере обновляется.

Для корректного обновления данных разработана следующая система:


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

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

//-----------------------------------------------------------------------
// сгенерировать версию данных
//-----------------------------------------------------------------------
DWORD CTE4RenderMaterial::GenUnicValueVersion( DWORD dwCurVersion, UINT uValueNum, UINT uCurPassNum )
{
  // принцип работы флага: значение флага меняется с 0 на 1
  // набор цифр определяет уникальность по: материалу, проходу, переменной

  // текущая версия переменной
  // нам нужно по первому символы определить значение перменной ( _ 000 000 000 ) и поменять его
  UINT dwCurFlag = dwCurVersion <= 2000000000 ? 1 : 0;

  // собираем перменную
  // 1  000 0    00    00
  // flag  material  pass  value
  dwCurVersion = 1000000000 * (dwCurFlag+1) + 10000 * (GetNumInManager()+1) + 100 * (uCurPassNum+1) + uValueNum;

  return dwCurVersion;
}
Кроме переменных в материале хранятся состояния рендера и текстур. К каждому типу данных есть код, который уникально определяет последовательность данных. Например, есть 4 текстуры, для них мы знаем номера семплеров. Эти данные в размере 8 мы можем закодировать следующим образом:

Код массива текстур = Сумма четырех{ (уникальный номер текстуры в движке + 1) * (номер семлпера + 1) + 50}

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

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

Проходом рендера (render pass) является группа объектов рендера, которые отображаются по общему принципу. Например, отображение объектов в карту теней — это отдельный проход рендера.

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

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

Система рендера создается из конфига (есть возможность определить его в рамках кода приложения), то есть нет в движке заранее заданных переменных, камер и проходов рендера. Это направлено на гибкость в построении методов отрисовки графики.

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

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

Кроме постоянных переменных есть текущие переменные. Они не создаются системой, система создает постоянное место, откуда материал сможет скопировать данные в шейдер. Текущая переменная создается в процессе рендера. Примером текущих переменных можно привести матрицы и вектора текущей камеры, текущая мировая позиция объекта, параметры источников света и т.д.

Текущие переменные нужны только для удобной и универсальной связи данных рендера и шейдеров.

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


Очередь отображения (render pipeline).

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

-Проход привязан к камере, чтобы что-то отобразить, проверяем включена ли камера.
-Работаем с вьюпорт и целями рендера, проверяем необходимость смены или восстановление базовых.
-Очищаем цели рендера при необходимости.
-Через материалы видимых камерой объектов рендера ищем те объекты, что нужно отобразить в текущем проходе. Генерируем код для сортировки, который задаст правильную последовательность.

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

Я решил заменить проверки на генерацию хеша и сортировку уже по нему. Во-первых, этот метод быстрее на 5-20% (в зависимости от того, нужно ли пересчитывать хеш постоянно или нет. Тестовая программа _TestRenderSort). А во-вторых, мне удобнее работать с хешированием.
Но некоторые весьма авторитетные источники (смотри комментарии) считают, что мой генератор хеш слишком кривой и в целом не влияет на производительность всего рендера, что подразумевает отсутствие какого-либо смысла его использовать. Решать вам.

//-----------------------------------------------------------------------
// подготовка к отображению
//-----------------------------------------------------------------------
void CTE4RenderSystemPass::PrepareRenderObject( LPRenderObject pRenderObject, size_t sPassNum )
{
  assert( pRenderObject->m_intrpVertexBufferMain.get() != NULL );

  // проход для сортировки
  pRenderObject->m_sCurPassNum = sPassNum;

  // кодируем все основные параметры отображения
  LPRenderMaterialPass pMaterialPass = pRenderObject->m_spRenderMaterial->m_stdvRenderPasses[sPassNum];

  // pRenderObject->m_i64CurVisibleState = 10100200001002
  // чтобы все влезло
  pRenderObject->m_i64CurVisibleState = 
    // эффект ( максимум 999)
    (unsigned __int64)(pMaterialPass->m_pEffect->GetNumInManager() + 1) * 10000000000000 +                  // 1 000 000 000 000 0
    // декларация (максимум 99)
    (unsigned __int64)(pRenderObject->m_spRenderMaterial->m_ipVertexDeclaration->GetNumInManager() + 1) * 100000000000 +  // 1 000 000 000 00
    // текстуры (максимум 999)
    (unsigned __int64)(pMaterialPass->m_dwTexturesCode + 1) * 100000000 +                          // 1 000 000 00
    // состояние рендера ( на 5 знаков )
    (unsigned __int64)(pMaterialPass->m_dwRenderStatesCode + 1) * 1000 +                          // 1 000 
    // буферы вершин (максимум 999)
    (unsigned __int64)(pRenderObject->m_intrpVertexBufferMain->GetNumInManager() + 1);                    // 1

}
Как видно при моем методе кодирования существуют ограничения на число ресурсов, которое в данный момент достигнуть я не смогу, но в будущем что-то придумывать придется. Хотя в рамках 64 битной переменной есть еще запас и кодирование очень простое.

-Сортируем объекты рендера по m_i64CurVisibleState коду объектов рендера. (обычный std::sort по возрастанию).

-Выставляем данные камеры как активные.
-Выставляем камеры источников света текущего прохода как активные.
-Цикл отображения объектов рендера в текущем проходе.


Отображения объекта в этом цикле:

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

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

-Камера отдельно находит точечные источники света при поиске видимых объектов. На данном этапе происходит поиск источников света для текущего объекта рендера.

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

-Установка точечных источников света с учетом кода. Если источники не меняются, то и в шейдере их обновлять не будем.
-Теперь пришло время собрать буферы шейдеров, так как все необходимые данные рассованы по местам.

При обновлении буфера учитываются версии переменных.

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

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

На этом рендер объекта заканчивается.

Заключение.

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

П.С. Как всегда, готов выслушать любые замечания по теме.
П.П.С. «Многокартиночности» опять не вышло, но кодом давить не стал.

Версия статьи 1.01.

#render, #Totem 4 Engine, #Игровые движки

8 июля 2011 (Обновление: 20 янв. 2012)

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