Если программирование подразумевает собой реализацию алгоритмов обработки данных на каком-либо языке, то метапрограммирование это реализация алгоритмов для работы с самими программами, представленными в виде исходного кода или еще как то. Проще говоря, метапрограммирование – это создание программ, создающих другие программы или манипулирующих ими и даже собой.
C# является ООП языком и предоставляет возможности практически на прямую использовать формальное описание объектной модели предметной области решаемой задачи. Среди основных понятий и принципов ООП отметим следующие – Абстракция данных, Инкапсуляция, Наследование, Полиморфизм.
Полиморфизм рассмотрим немного подробнее, так как именно его преимущества лежат в основе предлагаемого подхода. Полиморфным будет тот код, который при вызове какой либо функции с определенным (одним и тем же) именем использует различные типы передаваемых аргументов, что обеспечивает выполнение разного конечного кода. На практике это значит, что у нас есть возможность выполнять контекстно-зависимый код, где контекст определяется типом аргумента вызываемой функции. Собственно это и есть то преимущество полиморфизма, которое позволит нам создавать HLSL шейдеры на языке C#.
Полиморфизм глазами C#
Тут за примером ходить далеко не придется, полиморфизм используется практически везде. Возьмите рефлектор и откройте декларацию класса System.IO.BinaryWriter. У метода Write(..) есть 17 вариантов реализации. Кроме этого – вот еще небольшой пример для наглядности –
Предположим у нас есть структура для описания вершин –
1 2 3 4 5 6
struct VertexPositionNormalTexture { public Vector3 Position; public Vector3 Normal; public Vector2 TextureCoordinate; }
Теперь нам необходимо сохранить ее в бинарном виде. Для этого добавим еще «немного полиморфизма» в стандартный BinaryWriter. Для добавления используем наследование.
MyBinaryWriter уже содержит 19 вариантов реализации метода Write(..). Наша структура, теперь умеющая сохранять себя, будет выглядеть так –
1 2 3 4 5 6 7 8 9 10 11 12 13
struct VertexPositionNormalTexture { public Vector3 Position; public Vector3 Normal; public Vector2 TextureCoordinate; publicvoid Write(MyBinaryWriter writer) { writer.Write(Position); writer.Write(Normal); writer.Write(TextureCoordinate); } }
Получилось наглядно и работать с этим удобно будет...
Одной из разновидностей полиморфизма в C# является перегрузка операторов. XNA использует это для обеспечения наглядности при работе с векторными и матричными типами данных.
Сравните два метода, выполняющих одни и те же вычисления -
Эти методы равнозначны. Только первый из них реализован с учетом производительности (порядка 20% будет выигрыш). А второй с учетом наглядности и использует перегруженный оператор умножения.
См. рефлектор, struct Matrix - public static Matrix operator *(Matrix matrix1, Matrix matrix2);
Меташейдеры для XNA
Основная идея очень проста – что если в перегруженных операторах выполнять не сами вычисления, а попытаться сохранить их логику? Очевидно, что для формального описания логики вычислений нам понадобится ее объектная модель. Тут нужна модель, описывающая как ход этих вычислений, так и любой другой ход программы, возможный в рамках представляемого языка, в нашем случае HLSL. Благо рассматриваемый нами язык не так богат на возможности и классов из System.CodeDom нам вполне хватит.
Обратите внимание на реализацию метода ToString(). Мы используем CSharpCodeProvider, для теста этого достаточно но для более сложных вариантов понадобится свой HlslCodeProvider наследованный от базового.
Теперь мы можем во время исполнения видеть логику хода программы.
И главное можно не только наблюдать ее но и анализировать. Для более удобного анализа есть смысл построить свой CodeDom для Hlsl. Из которого убрать все лишнее. Добавить реализацию паттерна посетитель и пр.. Но это уже детали реализации, а цель этой статьи - раскрыть торию меташейдеров.
К слову о реализации - есть такой коммерческий 3D движек Visual3D, в нем используется похожий принцип. Система материалов построена на меташейдерах. В качестве бонуса к этой статье, если хватит времени до окончания конкурса, будет предложена более грамотная реализация рассматриваемого подхода. Правильный подход поразумевает использование объектой модели конечного языка (CodeDom), как это было показано в данной статье. В Visual3D в перегруженных операторах используют обычную конкатенацию строк. В результате имеем сразу исходник Hlsl, что ограничивает возможности анализа, манипулиции и оптимизации конечного кода.
На данный момент есть полурабочий прототип, позволяющий декларировать шейдеры на шарпе используя синтаксис схожий с HLSL -
struct VSInput { [POSITION] public float4 Position;// : POSITION; } publicstruct CommonVSOutput { public float4 Pos_ws; public float4 Pos_ps; public float4 Diffuse; public float3 Specular; public float1 FogFactor; } publicstruct VertexLightingVSOutput { [POSITION]// Position in projection space public float4 PositionPS;// : POSITION; [COLOR0] public float4 Diffuse;// : COLOR0; [COLOR1]// Specular.rgb and fog factor public float4 Specular;// : COLOR1; } publicstruct VertexLightingPSInput { [COLOR0] public float4 Diffuse;// : COLOR0; [COLOR1] public float4 Specular;// : COLOR1; } publicstruct VertexLightingPSInputTx { [COLOR0] public float4 Diffuse; [COLOR1] public float4 Specular; [TEXCOORD0] public float2 TexCoord; } public float1 FogStart; public float1 FogEnd; public float1 FogEnabled; public float3 FogColor;//: register(c3); public texture BasicTexture; private sampler TextureSampler = sampler_state( "BasicTexture", MipFilter.Linear, MinFilter.Linear, MagFilter.Linear); public float4x4 World;//: register(vs, c20); // 20 - 23 public float4x4 View;//: register(vs, c24); // 24 - 27 public float4x4 Projection;//: register(vs, c28); // 28 - 31 public float3 EyePosition;//: register(c4); // in world space public float3 DiffuseColor =1;//: register(c5) = 1; public float1 Alpha =1;//: register(c6) = 1; public float3 EmissiveColor =0;//: register(c7) = 0; public float3 SpecularColor =1;//: register(c8) = 1; public float1 SpecularPower =16;//: register(c9) = 16; public float1 ComputeFogFactor(float1 d) { return clamp((d - FogStart)/(FogEnd - FogStart), 0, 1)* FogEnabled; } public CommonVSOutput ComputeCommonVSOutput(float4 position) { CommonVSOutput vout; float4 pos_ws = mul(position, World); float4 pos_vs = mul(pos_ws, View); float4 pos_ps = mul(pos_vs, Projection); vout.Pos_ws= pos_ws; vout.Pos_ps= pos_ps; vout.Diffuse= float4(DiffuseColor.rgb+ EmissiveColor, Alpha); vout.Specular=0; vout.FogFactor= ComputeFogFactor(length(EyePosition - pos_ws)); return vout; } VertexLightingVSOutput VSBasic(VSInput vin) { VertexLightingVSOutput vout; CommonVSOutput cout = ComputeCommonVSOutput(vin.Position); vout.PositionPS= cout.Pos_ps; vout.Diffuse= cout.Diffuse; vout.Specular= float4(cout.Specular, cout.FogFactor); return vout; } [return: COLOR] public float4 PSBasic(VertexLightingPSInput pin)// : COLOR { float4 color = pin.Diffuse+ float4(pin.Specular.rgb, 0); color.rgb= lerp(color.rgb, FogColor, pin.Specular.w); return color; } [return:COLOR] public float4 PSBasicTx(VertexLightingPSInputTx pin)// : COLOR { float4 color = tex2D(TextureSampler, pin.TexCoord)* pin.Diffuse+ float4(pin.Specular.rgb, 0); color.rgb= lerp(color.rgb, FogColor, pin.Specular.w); return color; }
Это фрагмет шейдера BasicEffect из XNA framework. Логика выполнения функций отражена в перегруженных операторах и встроенных методах, таких как tex2D(), lerp(), mul(). Эти встроенные методы (Intrinsic Functions) определены в базовом классе шейдера и возвращают результат в виде MethodInvokeExpression. Все остальное (параметры, самплеры, техники) получаем используя рефлексию.
Тесты
Текущий прототип не может пока генерировать весь код HLSL эффекта из C# класса деларирующего шейдер. Но получить код отдельно взятых функций можно. Сравните то что получилось из вершинного шейдера объявленного выше -
HLSL код сгенерированный из С# (см. выше - строки 67-100)
Вершинный шейдер как будто стал короче )). Хотя на самом деле - появилось лишнее умножение - mul(vin.Position, World). На дебаг режим компиляции HLSL это повлияет, но в релизе оптимизатор это заметит и уберет. Полученный HLSL код визуально смотриться короче просто потому, что тут развернуты все вызовы локальных функций - ComputeCommonVSOutput(..) и ComputeFogFactor(..). Разворачивание локальных функций происходит автоматом, это даже не фича, специально реализованная, это следствие самого подхода к кодогенерации.
Теперь вставим полученный код в fx файл, добавим техник и параметров по аналогии с оригиналом, скомпилируем и сравним ASM с дизассемблером того же эффекта но собранного полностью из оригинального HLSL.
Как видно из результата, разворачивание локальных функций дает больше возможностей для вычисления прешейдера. Компиляция происходила в релиз режиме. Хотя HLSL компилятор сам разворачивает все вызовы, не зависимо от режима, просто потому, что ASM не поддерживает вызовов процедур. Однако не мотря на разворачивание самим компилятором - вычислить прешейдер из оригинального HLSL кода он все же не смог.
Результат для меня лично неожиданный. На уровне ощущений понимаю почему так получилось, но формально выразить не могу пока, не хватает знаний терминологии из области создания компиляторов.
Плюсы и минусы
На XBox работать не будет - нет возможности компилировать HLSL код в рантайме. При компиляции в Debug режиме можно получить более "тяжелый" HLSL/ASM код. Если клонировать такие шейдеры без рефлексии - много ручной работы. (свой Clone() нужен везде)
Один из основных плюсов это объектно ориентированный подход в создании шейдеров.
Shawn Hargreaves (один разработчиков XNA) в своем блоге пишет -
1 2 3 4 5
"So, I have this shader that does normalmapping, and this other shader that does skinned animation. How can I use them both to render an animated normalmapped character?" Welcome, my friend, to one of the fundamental unsolved problems of graphics programming...
Комбинирование шейдеров является одной из основных нерешенных проблем программирования графики.
Что ж, я считаю что предложенный в статье подход имеет потенциал решить эту проблему. Если работа над меташейдерами будет продолжена и поддержана нами всеми - в итоге мы сможем автоматом получить normalmapping + skinned шейдер имея только его составляющие.
А еще можно без ручного кодирования биндить параметры меташейдера к EffectParameter через рефлексию. ))