История разработки. Часть 2.2 Material System
В отличие от всех известных мне систем материалов для XashNT я выработал концепцию двухровнего слоя абстракции. Этот момент с одной стороны позволяет симулировать абсолютно любой синтаксис по вкусу пользователя (в рамках некоторых ограничений), а с другой лишает механизм привязки к внутренним абстракциям движка, позволяя привязаться непосредственно к графическому драйверу, что в свою очередь позволяет решать возникающие баги рендеринга средствами самой системы материалов, т.е. на высоком уровне, например делая какие-либо исключения для определённых типов видеокарт. Но обо всём по порядку.
Низкоуровневая часть системы материалов
Фундаментальный принцип низкоуровневой части заключается в построении материала вокруг пары вершинный-фрагментный шейдер.
В отличие от известных мне систем, где все данные подготавливаются непосредственно рендером и затем подаются в шейдер, я решил использовать обратный принцип. Во первых шейдер всё равно приоритетнее настроек материала, потому что на видеокарте исполняется именно он, а материал не более чем его бакэнд. То есть во времена Quake3 всё было наоборот, патч рендеринга формировался исходя из настроек материала и не имел никакой обратной связи. Для нефиксированного конвейера ситуация в корне поменялась, появилась обратная связь по факту компиляции шейдера.
Таким образом механизм выглядит так: материал->шейдер->материал. Это означает что механизм задаёт первичные условия, необходимые для компиляции шейдера, определённые пользователем. Далее, по результату компиляции, уже сам шейдер требует для своей работы определённый набор юниформов, текстурных юнитов и аттрибутов геометрии. Что именно ему требуется мы можем узнать, сделав запрос к драйверу. Это хороший путь к оптимизации, потому что мы не передаём юниформы, которые не требуются шейдеру, мы не загружаем в видеопамять текстуры, которые остались невостребованными, наконец мы имеем возможность гибко перестроить VBO-буффер, исключив из него те атрибуты, которые не требуются (да в XashNT такая возможность тоже присутствует). В конечном итоге это очень сильно экономит видеопамять и полностью контролируется
пользователем. Как всё это выглядит на практике:
Пользователь задаёт в описании материала те юниформы и те текстуры, которые потребуются шейдеру. Так же он может задавать макросы, которые будут переданы в шейдер на этапе компиляции, что позволяет реализовать механизм Uber-shader, хотя система материалов и не навязывает эту модель.
Происходит компиляция шейдера средствами графического драйвера.
По результатам компиляции подгружаются только используемые текстуры, передаются только задействованные юниформы, наконец анализируются все шейдеры, принадлежащие одному VBO-блоку, на предмет использования аттрибутов. Если какой-то атрибут не был востребован ни в одном из этих шейдеров, то VBO-геометрия динамически перестраивается на лету, исключая этот атрибут из финального массива, который будет
загружен в видеопамять. Ну например для рендеринга диффузная текстура + лайтмапа, нам не требуется аттриубут нормали у вертекса. Следовательно он будет исключен из VBO и его размер уменьшиться. Однако, если мы включим бамп-маппинг, то VBO будет опять перестроен
и аттрибут уже будет использоваться. Все эти перестроения выполняются на лету и не требуют полного рестарта игры, хотя и могут занимать какое-то время (от одной до пяти секунд), но это в любом случае быстрее, нежели полный рестарт, которым так грешат современные движки.
Кратко опишу основные функции и переменные низкоуровневой части. Мы используем переменные для установления связи с шейдером и функции
для задания определённых состояний конвейера. Функции интерпретируются как непосредственные команды драйверу.
fragShader( path_to_shader ); - функция загрузки фрагментного шейдера
vertShader( path_to_shader ); - функция загрузки вершинного шейдера
В дальнейшем планируется так же добавить геометрические и шейдеры тесселяции.
addShaderDefine( FOO ); - добавление некоего макроса в исходный текст шейдера.
Любое слово, добавленное таким образом развернётся в конструкцию #define FOO. То есть добавление кода в тело шейдера через этот механизм не предусмотрено, только константы-макросы.
removeShaderDefine( FOO ); - удаляет заданный макрос в силу заданных пользователем условий, для сложных механизмов иногда возникает
такая потребность.
image u_Tex = path_to_image; - переменная текстурного юнита. Самих юнитов может быть столько, сколько позволяет графический драйвер, ограничений в этом плане нет. Имя u_Tex будет использовано в шейдере.
float\vec2\vec3\vec4\int\ivec2\ivec3\ivec4\mat2\mat3\mat4 - допустимые типы для переменной юниформа. Например:
vec2 u_DetailScale = vec2( 10.0f, 10.0f );
Для переменных текстурного юнита, как и для юниформов допускаются не только заданные константы, но и ссылки на глобальные переменные внутри самого движка. Это делается через отсылку на ключевую структуру. Например:
float u_RealTime = globals->time;
Так же это могут быть и локальные переменные, взятые для конкретного объекта
mat4 u_ObjectTranform = entity->transform;
или даже консольная переменная
float u_DetailScale = cvar->name_of_cvar;
Так же для юниформов допускается использование регулярных выражений, которые будут выполняться в реальном времени. С полным списком всех допустимых значений вы сможете ознакомиться из документации по системе материалов, когда будет выпущена первая альфа движка для бета-тестирования. Полный перечень всех возможностей сильно перегрузил бы этот текст, а без возможности опробовать на практике принес бы только разочарование. Однако упомяну еще о двух функциях, т.к. у вас уже наверняка возникли эти вопросы. То что можно грузить любые текстуры, это безусловно хорошо, но как насчёт поиска этих текстур по разным путям? Ведь не каждая система материалов предполагает явное указание пути, да и сопутствующих текстур могут быть разные суффиксы. К примеру у карты нормалей может быть суффикс _bump, _nm, _local, _norm. Как учитывать всё это многообразие? Для этого служит функция
addImageLocation( alternatie_path_to_image );
Чем ниже по телу материала вы задаёте функцию, тем выше приоритет у пути. Таким образом в объявлении текстурного юнита можно прописать текстуру-заглушку по умолчанию, которая будет использована, если ни один из путей не окажется действительным. Пример:
image u_ColorMap = "globals->$WhiteTexture"; // fallback texture
addImageLocation( u_ColorMap, "textures/<wadname>/<texname>" );
Ключевые слова в угловых скобках, это специальные встроенные макросы, позволяющие формировать неявный путь к текстуре, чтобы не прописывать
явные пути каждый раз или для использования в дефолтной секции материала (о ней - чуть ниже). О назначении этих макросов я не буду сейчас писать по вышеуказанным причинам, но если у вас возникнут какие-то вопросы, вы можете задать их в комментариях.
Второе ключевое слово используется для создания анимации. Т.е. текстур, переключаемых из игровой логики или же с заданным FPS.
addUnitFrame( u_TexUnit, "texfolder/path.tga", <frameNumber>, optional<group> );
Обратите внимание, что третий параметр в вызове данной функции может вовсе отсутствовать, это выглядит как перегрузка функций в С++. Для более полного понимания сразу приведу рабочий пример использования:
// pev->frame == 0
addUnitFrame( u_ColorMap, "textures/<wadname>/+0runeyel", 0, 0 );
addUnitFrame( u_ColorMap, "textures/<wadname>/+1runeyel", 1, 0 );
setUnitFramerate( u_ColorMap, 10.0f, 0 );
// pev->frame == 1
addUnitFrame( u_ColorMap, "textures/<wadname>/+Aruneyel", 0, 1 );
setUnitFramerate( u_ColorMap, 10.0f, 1 );
Это реализация типичной кнопки из Quake1. В отжатом состоянии проигрывается анимация из двух кадров, в нажатом - показывается один кадр. Допускается по 8 групп по 32 кадра на любой геометрии (для сравнения в том же Quake1 возможно только две группы по 10 кадров).
Теоретически этих групп могло бы быть неограниченное кол-во, но на данный момент лимиты вот такие. Полагаю, что этого вполне достаточно. Для более длинных секвенций разумнее использовать видео-текстуры. Так же замечу, что addImageLocation это эквивалент addUnitFrame с нулевым кадром и нулевой группой. Функция SetUnitFramerate определяет тип контроля над переключением анимации - автоматически или из игровой логики.
Множественная декларация переменных\текстурных юнитов, не приводит к каким-либо ошибкам, последующие декларации просто игнорируются. Это не баг логики, а вполне законный трюк, который может быть использован при построении сложных материалов. Впрочем, существуют так же функции removeUniform и removeImageUnit, которые позволяют удалить объявленный выше и создать новый.
Упомянутые выше функции установки состояния конвейера, представляют собой привычные функции для переключения стейтов:
depthMask, depthRange, frontFace, cullFace, depthFunc, alphaFunc, blendFunc, polygonMode, polygonOffset.
Т.е. то, на что, непосредственно из шейдера повлиять либо не получится, либо сопряжено с определёнными неудобствами использования. Оставшуюся часть низкоуровнего функционала я не буду здесь описывать, поскольку это заняло бы очень много места, а моя цель дать вам общее представление о системе материалов, а не углубляться в мельчайшие подробности в ознакомительной статье.
Высокоуровневая часть системы материалов
После беглого знакомства с низкоуровневой частью системы возникает закономерный вопрос: да, это безусловно гибко и позволяет задавать любые комбинации настроек для любого материала не внося изменений в движок, но ведь это придётся делать для каждого материала? Слишком уж много всего придется прописывать. Это совсем неудобно. В качестве примера можно вспомнить всё тот же Quake3, который позволял обходится без явной декларации материала, если тот укладывался в стандартное представление - текстура + лайтмапа. Но если бы мы захотели в этот материал добавить какой-то эффект, то нам бы пришлось сначала полностью описать дефолтное поведение и только потом уже добавлять наш новый эффект. А если материалов с таким эффектом будет множество? Очевидно для каждого материала это придётся прописывать снова и снова. Системы материалов, упомянутые мной в первой части, явно или неявно пропагандируют аналогичную модель - по умолчанию они используют материалы PBR, для пользовательского материала, судя по всему придётся явным образом всё указывать заново. Какие же инструменты я предлагаю для облегчения этой работы?
Ну во первых, у каждого материала есть две секции (на самом деле три, но обсуждение третьей секции выходит за рамки данного обзора): дефолтная и пользовательская. Дефолтная секция имеет имя, образованное от MD5 Hash комбинации имён атрибутов VBO-геометрии и используется для этой геометрии всегда. Пользовательская секция выглядит следующим образом:
"material_name"
{
}
Она может оставаться пустой, она может отсутствовать, она может полностью или частично отменять последующие настройки дефолтной секции. Таким образом работа по настройке материалов значительно облегчается, однако всё равно может оставаться значительной. И тут в дело
вступают основные механизмы высокоуровневой системы материалов - мощные инструменты препроцессора и автозамены. Данный механизм
даже мощнее чем в компиляторах С\С++ и насколько мне известно не имеет аналогов нигде. Рассмотрим его подробнее.
Во первых это типичный набор условий препроцессора: #define, #if, #ifdef, #ifndef, #elif, #else, #endif
Важный момент: в качестве переменных для этих условий могут выступать:
- константы графического драйвера, например (GL_EXT_TEXTURE_CUBEMAP, GL_EXT_GPU_SHADER4, GL_MAX_PROGRAM_UNIFORMS и другие)
- внутренние константы самого движка, например (MODEL_HAS_LIGHTMAP, WORLD_HAS_GLOBALFOG, AUTOGENERATED_MATERIAL и другие)
- заданные выше юниформы для шейдера (сравнение значений)
- заданные выше текстурные юниты (проверяется реальное наличие текстуры на жестком диске)
- заданные выше константы при помощи ключевого слова #define
- значения консольных переменных
Этот механизм позволяет гибко перестраивать рендерер в зависимости от внешних условий - опираться на поддерживаемые расширения видеокарты или включать те или иные эффекты по запросу пользователя. Правда он не сокращает работу по описанию материалов, однако это всё часть единой системы и я счёл своим долгом ознакомить читателя и с этой частью тоже. Непосредственно за автозамену отвечают три ключевых слова:
#define a b
Здесь мы просто подменяем a на b. Запись всегда в одну строку.
#keydef a b c d e f g\
replaced\
replaced\
replaced
#block a b c\
{ replaced }\
Собственно, ключевое слово #keydef и есть тот самый мощнещий инструмент автозамены, а #block - это его расширение, для секций с фигурными скобками, когда кол-во аргументов изначально неизвестно и может варьироваться в широких пределах. Рассмотрим чуть подробнее. На примере симуляции каких-либо параметров из описания материалов того же Quake3
#keydef q3map_surfacelight <a>\
float c_SurfaceLight = <a>;
Здесь мы ищем пару q3map_surfacelight <anyvalue> и передаём её в переменную c_SurfaceLight. Более простой пример, без параметров:
#keydef q3map_notjunc\
addCompileFlags( C_NOTJUNC );
Сложный пример, с объявлением источника света в материале:
#keydef q3map_sun <a> <b> <c> <d> <e> <f>\
vec4 c_sunLight<cSunCount> = vec4( <a>, <b>, <c>, <d> );\
vec4 c_sunParms<cSunCount> = vec4( <e>, <f>, 0.0, 0.0 );\
cSunCount++;
Здесь уже необходимо дать некоторые пояснения. Юниформы c_sunLight и c_sunParams - это зарезервированные переменные для компилятора уровней, которые будут использованы для генерации статичных источников света. Передаваемые в юниформы значения берутся непосредственно из аргументов макроса и могут быть любыми (теми, которые задал пользователь в материале). То что аргумент не константа, сигнализируют угловые скобки, в которые он заключен. Отдельно стоит упомянуть про переменную cSunCount. Она имеет тип enum и ограниченный набор операций, которого, тем не менее хватает для реализации систем материалов практически любой сложности. Это - прямое присвоение значение, постинкремент, постдекремент и чтение этого значения, как для условий #if, так и для формирования нового имени переменной. В данном примере мы наблюдаем именно второй случай - значение переменной cSunCount используется для формирования имени c_sunLight. Таким образом каждый новый вызов этого макроса создаёт нам последовательно юниформы с именами c_sunLight0, c_sunLight1, c_sunLight2 и.т.д.
Сами переменные типа enum никуда не передаются, однако вы можете вручную записать их текущие значения в юниформы, если вам вдруг это
понадобится. Рассмотрим более сложный случай - описание материала скайбокса, который меняет поведение, в зависимости от наличия той или иной текстуры и может использовать как сферические облака, бегущие по небу (стиль Quake1) так и рендеринг скайбокса.
#keydef QuakeSky\
image c_ImplicitImage = "textures/<wadname>/<texname>_solid";\
image u_ColorMap = "gfx/env/<skyname>";\
#if u_ColorMap\
addImageFlags( u_ColorMap, TF_CLAMP );\
vertShader( "glsl/skybox_vp.glsl" );\
fragShader( "glsl/skybox_fp.glsl" );\
#else\
// u_ColorMap is no longer used
removeImageUnit( u_ColorMap );\
vertShader( "glsl/quakesky_vp.glsl" );\
fragShader( "glsl/quakesky_fp.glsl" );\
image u_SolidLayer = "globals->$GrayTexture";\
image u_AlphaLayer = "globals->$BlackTexture";\
addImageLocation( u_SolidLayer, "textures/<wadname>/<texname>_solid" );\
addImageLocation( u_AlphaLayer, "textures/<wadname>/<texname>_alpha" );\
addImageFlags( u_SolidLayer, TF_NOMIPMAP );\
addImageFlags( u_AlphaLayer, TF_NOMIPMAP );\
#endif\
addCompileFlags( C_NOCSG|C_SKY|C_NOLIGHTMAP );\
removeContentFlags( CONTENTS_SOLID );\
addShaderDefine( SKYPARMS );
Также обращаю ваше внимание, что отсутствие слэша на последней строке означает конец описания автозамены, впрочем в компиляторах С\С++ используется аналогичный механизм. Итак, первоначально здесь задаётся путь "gfx/env/<skyname>", где <skyname> это поле в объекте "worldspawn"
с именем скайбокса. Условие #if u_ColorMap проверяет, что скайбокс по данному пути действительно существует. Если это так, то используется шейдер рендеринга скайбокса. Если же нет, то альтернативная ветвь включает режим рендеринга облаков на полу-сфере. Данный макрос пишется один раз и в дальнейшем не требует внесения каких-либо изменений. А вот описание материала неба уже выглядит следующим образом:
sky003
{
QuakeSky
}
sky1
{
QuakeSky
}
sky4
{
QuakeSky
}
Как видите - очень просто. Всего одно ключевое слово вместо целого листинга. Так же #keydef допускает перегрузку макросов, используя одно ключевое слово, но разное кол-во аргументов. Пример:
#keydef animMap <fps> <frame0>\
addImplicitImage <frame0>\
image u_ColorMap<stageNum> = "<frame0>";
#keydef animMap <fps> <frame0> <frame1>\
addImplicitImage <frame0>\
image u_ColorMap<stageNum> = "<frame0>";\
addUnitFrame( u_ColorMap<stageNum>, <frame1>, 1 );\
setUnitFramerate( u_ColorMap<stageNum>, <fps> );
#keydef animMap <fps> <frame0> <frame1> <frame2>\
addImplicitImage <frame0>\
image u_ColorMap<stageNum> = "<frame0>";\
addUnitFrame( u_ColorMap<stageNum>, <frame1>, 1 );\
addUnitFrame( u_ColorMap<stageNum>, <frame2>, 2 );\
setUnitFramerate( u_ColorMap<stageNum>, <fps> );
Это ключевое слово описание анимации из Quake3. Мы можем задавать пути к нескольким текстурам подряд (обычно до 8 штук). Я не стал здесь
приводить все 8 макросов, но принцип, думаю, понятен. Внутри макросов автозамены допускаются абсолютно те же самые конструкции, что и в описании самих материалов, важно лишь не забывать ставить слэш в конце каждой строчки.
Разумеется данный механизм обладает мощной отладочной системой и всегда выдаст в консоль ошибку, в случае если что-то пойдет не так, с указанием имени файла и номера строчки. Так же существует функция #msg, позволяющая "подсмотреть" значения юниформов, текстурных юнитов или enum-переменных. В общем любых переменных, доступных внутри материала. Это может помочь при отладке, поскольку система довольно сложная.
О возможностях системы могу сказать следующее - с её помощью мне удалось без особых проблем воспроизвести функционал описания материалов из Quake3 и Doom3. Полагаю, что для большинства уже имеющихся систем описания материалов не составит особого труда воссоздать их функционал персонально для XashNT. Зачем это нужно? Пользователи не любят переучиваться, им привычнее работать с определённой системой. К тому же, как вы понимаете, всё это легко расширяется практически до бесконечности. Можно создавать свой уникальный язык описания материалов, можно эмулировать уже существующий. К тому же у разных типов игр - разные потребности. Где-то описания материалов практически не требуется и вся загрузка текстур происходит в полностью автоматическом режиме. А где-то нужна тонкая индивидуальная подстройка.
Завершая обзор отвечу на еще один вопрос, который мог у вас возникнуть: гибкая автозамена это конечно хорошо, но ведь это работа с десятками
и сотнями текстовых файлов, это может очень сильно замедлить загрузку движка? Нет не замедлит. Материалы при старте кэшируются с частичной
компиляцией. В кэш попадает описание материала только с низкоуровневыми вызовами, автозамены исключаются на этапе кэширования. Разумеется данная компиляция не может быть полной, поскольку в материалах слишком многое перестраивается динамически, однако использование такого кэша сокращает время загрузки материалов 3-4 секунд до 0.1 даже на довольно слабых компьютерах. Особенно это касается агрессивной автозамены, о которой в рамках этого обзора я так и не упомянул, но о которой вы обязательно узнаете из документации, когда придёт время для бета-тестирования XashNT. На сегодня у меня всё, в следующий раз мы поговорим с вами о формате игровых уровней, о том какой большой путь мне пришлось для этого проделать, какие подводные камни на пути я встретил, и как мне удалось решить большую часть возникших проблем. Статья вероятно будет содержать множество важной информации, которую вы вряд ли найдете где-то еще, поэтому я планирую выпустить её только для подписчиков верхних уровней.
Если вам действительно интересна эта тема, то самое время задуматься над улучшением подписки.