Введение

Ни для кого уже не секрет, что Flash Player 11 имеет поддержку GPU ускорения графики. Новая версия вводит Molehill API, позволяя работать с видеокартой на достаточно низком уровне, что с одной стороны даёт полную волю фантазии, с другой требует более глубокого понимания принципов работы современной 3D графики.

В данной статье речь пойдёт о языке написания шейдеров - AGAL (Adobe Graphics Assembly Language). Предполагается, что читатель знаком с базовыми основами современной realtime 3D графики, а в идеале - имеет опыт работы с OpenGL или Direct3D. Для остальных же проведу небольшой экскурс:

Синтаксис

В текущей реализации AGAL используется обрезок Shader Model 2.0, т.е. фичелист железа ограничен 2005 годом. Но стоит помнить, что это ограничение лишь возможностей шейдерной программы, но никак не производительности железки. Возможно, в будущих версиях Flash Player планка будет поднята до SM 3.0, и мы сможем рендерить сразу в несколько текстур и делать текстурную выборку прямо из вершинного шейдера, но учитывая политику Adobe, случится это не раньше выхода следующего поколения мобильных устройств.

Любая программа на AGAL является по сути низкоуровневым языком ассемблера. Сам по себе язык очень простой, но требует изрядной доли внимательности. Код шейдера представлен набором инструкций вида:
opcode [dst], [src1], [src2]
что в вольной трактовке означает «выполнить команду opcode с параметрами src1 и src2, вернув значение в dst». Шейдер может содержать до 256 инструкций. В качестве dst, src1 и src2 выступают имена регистров: va, vc, fc, vt, ft, op, oc, v, fs. Каждый из этих регистров, за исключением fs, является четырёхмерным (xyzw или rgba) вектором. Существует возможность работы с отдельными компонентами вектора, в том числе и swizzling (иной порядок):
dp4 ft0.x, v0.xyzw, v0.yxww
Рассмотрим каждый из типов регистров подробнее.

Регистр-вывода

В результате расчёта вершинный шейдер обязан записать значение оконной позиции вершины в регистр op (output position), а фрагментный – в oc (output color) значение итогового цвета пикселя. В случае с фрагментным шейдером существует возможность отмены обработки инструкцией kil, которая будет описана ниже.

Регистр-атрибут

Вершина может содержать в себе до 8 атрибутов-векторов, обращение к которым из шейдера осуществляется через регистры va, положение которых в вершинном буфере задаётся функцией Context3D.setVertexBufferAt. Данные атрибута могут быть формата FLOAT_1, FLOAT_2, FLOAT_3, FLOAT_4 и BYTES_4. Число в названии обозначает количество компонент вектора. Стоит отметить, что в случае с BYTES_4 значения компонентов нормализуются, т.е. делятся на 255.

Регистр-интерполятор

Помимо записи в регистр op, вершинный шейдер может передать до 8 векторов в фрагментный шейдер через регистры v. Значения этих векторов будут линейно интерполированы по всей площади полигона во время растеризации. Проиллюстрируем работу интерполяторов на примере треугольника, в вершинах которого хранится атрибут, выводимый фрагментным шейдером:
// vertex mov op, va0 // первый атрибут - позиция mov v0, va1 // второй атрибут передаём в шейдер как интерполятор // fragment mov oc, v0 // возвращаем полученный интерполятор в качестве цвета

Регистр-переменная

В вершинном и фрагментном шейдерах доступно до 8 регистров vt и ft для хранения промежуточных результатов расчёта. Например, в фрагментном шейдере необходимо посчитать сумму четырёх векторов, принятых из вершинной программы (v0..v3 регистры):
add ft0, v0, v1 // ft0 = v0 + v1 add ft0, ft0, v2 // ft0 = ft0 + v2 add ft0, ft0, v3 // ft0 = ft0 + v3
В результате ft0 будет хранить нужную нам сумму, и всё вроде бы здорово, но существует неочевидная на первый взгляд возможность оптимизации, которая напрямую связана с архитектурой работы программного конвейера видеокарты и отчасти является причиной её высокой производительности.

В основу шейдеров заложена концепция ILP (Instruction-level parallelism), которая уже, судя из названия, позволяет выполнять несколько инструкций одновременно. Основным условием для задействования этого механизма, является независимость инструкций друг от друга. Применительно к примеру выше:
add ft0, v0, v1 // ft0 = v0 + v1 add ft1, v2, v3 // ft1 = v2 + v3 add ft0, ft0, ft1 // ft0 = ft0 + ft1
Первые две инструкции выполнятся одновременно, т.к. работают с независимыми регистрами. Отсюда следует вывод, что ключевую роль в производительности вашего шейдера играет не столько количество инструкций, сколько их независимость друг от друга.

Регистр-константа

Хранение численных констант прямо в коде шейдера не допускается, т.е. все необходимые для работы константы должны быть переданы в шейдер до вызова Context3D.drawTriangles, и будут доступны в регистрах vc (128 векторов) и fc (28 векторов). Существует возможность обращения к регистру по его индексу используя квадратные скобки, что весьма удобно при реализации скелетной анимации или индексирования материалов. Важно помнить, что операция задания шейдерных констант относительно дорогая, и её следует по возможности избегать. Так например, нет смысла передавать в шейдер матрицу проекции перед рендером каждого объекта, если она не меняется в текущем кадре.

Регистр-семплер

В фрагментный шейдер можно передать до 8 текстур функцией Context3D.setTextureAt, обращение к которым осуществляется через соответствующие регистры fs, которые используются исключительно в операторе tex. Немного изменим пример с треугольником, и в качестве второго атрибута вершины передадим текстурные координаты, а в фрагментном шейдере сделаем текстурную выборку по этим уже интерполированным координатам:
// vertex mov op, va0 // позиция mov v0, va1 // второй атрибут - текстурная координата // fragment tex oc, v0, fs0 <2d,linear> // выборка из текстуры

Операторы

На данный момент (октябрь 2011), AGAL реализует следующие операторы:
mov dst = src1 neg dst = -src1 abs dst = |src1| add dst = src1 + src2 sub dst = src1src2 mul dst = src1 * src2 div dst = src1 / src2 rcp dst = 1 / src1 min dst = min(src1, src2) max dst = max(src1, src2) sat dst = max(min(src1, 1), 0) frc dst = src1floor(src1) sqt dst = src1^0.5 rsq dst = 1 / (src1^0.5) pow dst = src1^src2 log dst = log2(src1) exp dst = 2^src1 nrm dst = normalize(src1) sin dst = sine(src1) cos dst = cosine(src1) slt dst = (src1 < src2) ? 1 : 0 sge dst = (src1 >= src2) ? 1 : 0 dp3 скалярное произведение dst = src1.x*src2.x + src1.y*src2.y + src1.z*src2.z dp4 скалярное произведение всех четырёх компонент вектора dst = src1.x*src2.x + src1.y*src2.y + src1.z*src2.z + src1.w*src2.w crs векторное произведение dst.x = src1.y * src2.z – src1.z * src2.y dst.y = src1.z * src2.x – src1.x * src2.z dst.z = src1.x * src2.y – src1.y * src2.x m33 умножение вектора на матрицу 3х3 dst.x = dp3(src1, src2[0]) dst.y = dp3(src1, src2[1]) dst.z = dp3(src1, src2[2]) m34 умножение вектора на матрицу 3х4 dst.x = dp4(src1, src2[0]) dst.y = dp4(src1, src2[1]) dst.z = dp4(src1, src2[2]) m44 умножение вектора на матрицу 4х4 dst.x = dp4(src1, src2[0]) dst.y = dp4(src1, src2[1]) dst.z = dp4(src1, src2[2]) dst.w = dp4(src1, src2[3]) kil отмена обработки фрагмента прекращает выполнение фрагментного шейдера, если значение src1 меньше нуля, обычно используется для реализации alpha-test, когда нет возможности сортировки порядка полупрозрачных объектов. tex выборка значения из текстуры заносит в dst значение цвета в координатах src1 из текстуры src2 также принимает дополнительные параметры, перечисленные через запятую, например: tex ft0, v0, fs0 <2d,repeat,linear,miplinear> данные параметры нужны для обозначения: формата текстуры 2d, cube фильтрации nearest, linear мипмаппинга nomip, miplinear, mipnearest тайлинга clamp, repeat

Остальные операторы, включая условные переходы и циклы планируются реализовать в последующих версиях Flash Player. Но это не означает, что сейчас нельзя использовать даже обычный if, инструкции slt и sge вполне подходят для этих задач.

Эффекты

С основами ознакомились, теперь самая интересная часть статьи – практическое применение новых знаний. Как говорилось в самом начале, возможность писать шейдера полностью развязывает руки программисту графики, т.е. фактические ограничения лишь в фантазии и математической смекалке разработчика. Ранее можно было убедиться, что сам по себе ассемблерный язык прост, но за простотой скрывается сложность “вкуривания” в уже забытый код. Поэтому крайне рекомендую комментировать ключевые участки кода шейдера, дабы быстро в нём ориентироваться в случае необходимости.

Заготовка

Отправной точкой для всех последующих примеров будет небольшая “болванка” в виде чайника. В отличие от примера с треугольником, нам понадобится матрица проекции и трансформации камеры, для создания эффекта перспективы и вращения вокруг объекта. Её мы передадим в константные регистры. Тут важно помнить, что матрица 4х4 занимает ровно 4 регистра, и при записи её в регистр vc0, занятыми окажутся v0..v3. Также нам пригодится константный вектор из часто используемых в шейдере чисел (0.0, 0.5, 1.0, 2.0).

Итого, базовый код шейдера будет выглядеть так:
// vertex m44 op, va0, vc0 // применяем viewProj матрицу // fragment mov ft0, fc0.xxxz // занесём в ft0 чёрный непрозрачный цвет mov oc, ft0 // вернём ft0 в качестве цвета пикселя

Texture mapping

В шейдере возможно наложение до 8 текстур, при практически неограниченном числе выборок. Это означает, что данный лимит не имеет особого значения при использовании атласов или кубических текстур. Усовершенствуем наш пример и, вместо задания цвета в фрагментном шейдере, будем получать его из текстуры по текстурным координатам-интерполяторам, принятым из вершинного шейдера:
// vertex ... mov v0, va1 // передаём в фрагментный шейдер текстурную координату // fragment tex ft0, v0, fs0 <2d,repeat,linear,miplinear>

Lambert shading

Самая примитивная модель освещения, имитирующая реальное. Основана на положении, что интенсивность света, упавшего на поверхность, линейно зависит от косинуса угла между векторами падения и нормали к поверхности. Из школьного курса математики вспомним, что скалярное произведение единичных векторов даёт косинус угла между ними, следовательно, наша формула освещения по Ламберту будет иметь вид:
Lambert = Diffuse * ( Ambient + max( 0, dot( LightVec, Normal ) ) )
Color = Lambert

где Diffuse – цвет объекта в точке (взятый из текстуры например),
Ambient – цвет фонового освещения,
LightVec – единичный вектор из точки на источник света,
Normal – перпендикуляр к поверхности,
Color – итоговый цвет пикселя,

Шейдер будет принимать два новых константных параметра: позицию источника и значение фонового света:
// vertex ... mov v1, va2 // v1 = normal sub v2, vc4, va0 // v2 = lightPos - vertex (lightVec) // fragment ... nrm ft1.xyz, v1 // normal ft1 = normalize(lerp_normal) nrm ft2.xyz, v2 // lightVec ft2 = normalize(lerp_lightVec) dp3 ft5.x, ft1.xyz, ft2.xyz // ft5 = dot(normal, lightVec) max ft5.x, ft5.x, fc0.x // ft5 = max(ft5, 0.0) add ft5, fc1, ft5.x // ft5 = ambient + ft5 mul ft0, ft0, ft5 // color *= ft5

Phong shading

Вводит понятие блика от источника света в модель освещения по Ламберту. Подразумевает, что интенсивность блика определяется степенной функцией по косинусу угла между вектором на источник и направления, получившегося в результате отражения вектора наблюдателя относительно нормали к поверхности.
Phong = pow( max( 0, dot( LightVec, reflect(-ViewVec, Normal) ) ), SpecularPower ) * SpecularLevel
Color = Lamber + Phong

где ViewVec – вектор взгляда наблюдателя,
SpecularPower – степень, определяющая размер блика,
SpecularLevel – уровень интенсивности блика или его цвет,
reflect – функция вычисления отражения f(v, n) = 2 * n * dot(n, v) – v

Для сложных моделей принято использовать Specular и Gloss карты, которые определяют цвет/интенсивность (SpecularLevel), а также размер блика (SpecularPower) на разных участках текстурного пространства модели. В нашем случае, обойдёмся константными значениями степени и интенсивности. В вершинный шейдер передадим новый параметр – позицию наблюдателя для последующего вычисления ViewVec:
// vertex ... sub v3, va0, vc5 // v3 = vertex - viewPos (viewVec) // fragment ... nrm ft3.xyz, v3 // viewVec ft3 = normalize(lerp_viewVec) // расчёт вектора отражения reflect(-viewVec, normal) dp3 ft4.x, ft1.xyz ft3.xyz // ft4 = dot(normal, viewVec) mul ft4, ft1.xyz, ft4.x // ft4 *= normal add ft4, ft4, ft4 // ft4 *= 2 sub ft4, ft3.xyz, ft4 // reflect ft4 = viewVec - ft4 // phong dp3 ft6.x, ft2.xyz, ft4.xyz // ft6 = dot(lightVec, reflect) max ft6.x, ft6.x, fc0.x // ft6 = max(ft6, 0.0) pow ft6.x, ft6.x, fc2.w // ft6 = pow(ft6, specularPower) mul ft6, ft6.x, fc2.xyz // ft6 *= specularLevel add ft0, ft0, ft6 // color += ft6

Normal mapping

Относительно простой метод для имитации рельефа поверхности посредством использования текстуры нормалей. Направление нормали в такой текстуре принято задавать в виде RGB значения, полученного из приведения её координат к диапазону 0..1 (xyz * 0.5 + 0.5). Нормали могут быть представлены как в пространстве объекта (Object Space), так и в относительном пространстве (Tangent Space), построенном на базисе текстурных координат и нормали к вершине. Первый имеет ряд порой значительных недостатков в виде большого расхода памяти под текстуры из-за невозможности тайлинга и mirror-текстурирования, но позволяет сэкономить на количестве инструкций. В примере будем использовать более гибкий и общий вариант с Tangent Space, для которого помимо нормали потребуется ещё два дополнительных вектора базиса Tangent и Binormal. Реализация сводится к переводу векторов viewVec и lightVec к TBN (Tangent, Binormal, Normal) базису, и дальнейшей выборке относительной нормали из текстуры в фрагментном шейдере.
// vertex ... // transform lightVec sub vt1, vc4, va0 // vt1 = lightPos - vertex (lightVec) dp3 vt3.x, vt1, va4 dp3 vt3.y, vt1, va3 dp3 vt3.z, vt1, va2 mov v2, vt3.xyzx // v2 = lightVec // transform viewVec sub vt2, va0, vc5 // vt2 = vertex - viewPos (viewVec) dp3 vt4.x, vt2, va4 dp3 vt4.y, vt2, va3 dp3 vt4.z, vt2, va2 mov v3, vt4.xyzx // v3 = viewVec // fragment tex ft1, v0, fs1 <2d,repeat,linear,miplinear> // ft1 = normalMap(v0) // 0..1 to -1..1 add ft1, ft1, ft1 // ft1 *= 2 sub ft1, ft1, fc0.z // ft1 -= 1 nrm ft1.xyz, ft1 // normal ft1 = normalize(normal) ...

Toon Shading

Разновидность нефотореалистичной модели освещения, имитирующая мультипликационную рисовку затенения. Реализуется множеством способов, самым простым из которых является выборка цвета из 1D текстуры по косинусу угла из модели Ламберта. В нашем случае, для примера используем текстуру 16x1:

// fragment ... dp3 ft5.x, ft1.xyz, ft2.xyz // ft5 = dot(normal, lightVec) tex ft0, ft5.xx, fs3 <2d,nearest> // color = toonMap(ft5)

Sphere mapping

Самый простой вариант для имитации отражения, чаще используемый для эффекта хромирования металла. Представляет окружение в виде текстуры со сферическим искажением по типу “рыбий глаз”, как показано ниже:

Основная задача сводится к преобразованию координат вектора отражения в соответствующие текстурные координаты:
uv = ( xy / sqrt(x^2 + y^2 + (z + 1)^2) ) * 0.5 + 0.5
Умножение и сдвиг на 0.5 нужны для приведения нормированного результата к пространству текстурных координат 0..1. В простом случае для идеально отражающей поверхности, влияние карты аддитивное, а для более сложных случаев когда требуется диффузная составляющая, принято использовать приближение формул Френеля. Также для комплексных моделей часто используются Reflection карты, указывающие интенсивность отражения разных частей текстуры модели.
// fragment ... add ft6, ft4, fc0.xxz // ft6 = reflect (x, y, z + 1) dp3 ft6.x, ft6, ft6 // ft6 = ft6^2 rsq ft6.x, ft6.x // ft6 = 1 / sqrt(ft6) mul ft6, ft4, ft6.x // ft6 = reflect / ft6 mul ft6, ft6, fc0.y // ft6 *= 0.5 add ft6, ft6, fc0.y // ft6 += 0.5 tex ft0, ft6, fs2 <2d,nearest> // color = reflect(ft6)

Заключение

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

Скачать пример к статье