Пишем шейдер на AGAL

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

В данной статье речь пойдёт о языке написания шейдеров — AGAL (Adobe Graphics Assembly Language). Предполагается, что читатель знаком с базовыми основами современной realtime 3D графики, а в идеале — имеет опыт работы с OpenGL или Direct3D. Для остальных же проведу небольшой экскурс:
  • в каждом кадре всё рендерится заново, подходы с частичной перерисовкой экрана крайне нежелательны
  • 2D – частный случай 3D
  • видеокарта способна растеризовать треугольники и ничего кроме
  • треугольники строятся на вершинах
  • каждая вершина содержит в себе атрибуты (координата, нормаль, вес и др.)
  • порядок задания вершин в треугольнике определяется индексами
  • данные вершин и индексов хранятся в вершинном и индексном буферах соответственно
  • шейдер – программа выполняемая видеокартой
  • каждая вершина проходит через вершинный шейдер, а каждый пиксель при растеризации через фрагментный (пиксельный)
  • видеокарта не умеет работать с целыми числами, но отлично работает с 4D векторами
Синтаксис
В текущей реализации 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 = abs(src1)
        add     dst = src1 + src2
        sub     dst = src1 – src2
        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 = src1 – floor(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)

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

Заключение
Игры на флеше – это просто! пример к статье.
  • +21

Комментарии (17)

+2
Хотелось бы отметить, что количество инструкций, судя по документации, ограничено 200, а не 256.
Спасибо, за статью, очень познавательно.
  • ryzed
  • ryzed
0
Угу, врут 8)
+3
+
Вряд ли мои мозги когда нибудь смогут такое переварить, но автору респект...:)
+1
Присоединяюсь. Кроме картинок ничего не понял ;D
0
Часто у каждого устройства свой язык микрокоманд — Ассемблер. У процессора свой, у видюхи — свой, у спец-устройства (микроконтроллера какого-нить) — свой.

Не так и сложно — короткие команды с 2-3 параметрами.

В институтах часто Ассемблер преподают, Нужно было специальность тщательней выбирать:) Хотя мне кажется, в Ассемблере более менее у нас разобралось не более 1%. Я, например, особых успехов в нём не достиг. Больше привлекают высокоуровневые языки. AS3, кстати, чересчур выосокоуровневый (т.к. на виртуальной машине работает):)
0
Да я не ною. Специальность действительно не та. Жалеть не жалею, ведь я открываю для себя новое и радуюсь каждый раз ;D
Тут для меня, конечно, совсем неведомая хрень описана; и только из-за того, что мне оно не нужно.
Я как не программист люблю as3. Из тех немногих языков программирования, с которыми я сталкивался, as3 мне ближе, удобнее и понятнее.
+1
Мне тоже… Хотя есть опыт с С++, C# и Java. А AS3 очень многим нравится. Он действительно клёвый и удобный:)
0
кстати все становится проще когда начинаешь пробовать что-либо сделать :)
+1
инструкции кода это здорово, но от Адоби, хотелось бы ожидать какие нибудь GLHL/HLSL, эх)

Плюсую за гаечку =)
0
А нет ли какого компилера в AGAL из HLSL?
Ато на HLSL я когда то много писал, а вот этот «ассамблер» меня пугает.
  • FreeS
  • FreeS
+1
pixelbender, hxsl, еще есть какие-то штуки.
Но все работает через одно место.
0
В конце сентября вышел новый Pixel Bender 3D. Работает отлично, баг с флагами сэмплинга исправили. Мне нравится больше, чем чистый AGAL.
+1
Ради каждого изменения в шейдере:
1. Менять текстовый файл
2. Запускать pbutil
3. Перекомпилировать флэшку

Как-то не очень радует.
+1
Хм.
Ради каждого изменения в шейдере AGAL:
1. Менять текстовый файл (исходный код)
2. Перекомпилировать флэшку

Ради каждого изменения в шейдере в Pixel Bender 3D:
1. Менять текстовый файл
2. Перекомпилировать флэшку с включенным pbutil в Pre-Build запуск.

Вполне радует.
0
Полагаю, чтобы не менять специализацию, стоит подождать написанных библиотек, реализующих вышеописанное методами as3 )
+1
Добавлю: новое всегда интересно изучать, но нового у нас и так в каждом проекте дофига, а времени на семью как-то уже не так )
0
+
О Г Р О М Н О Е С П А С И Б О.
очень интересно и полезно.
скачал. запустил. все работает. начал разбираться.
1-й вопрос. а что это за формат модели чайника?
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.