среда, 29 апреля 2009 г.

Magic Shaders: Genie Effect

Привет!

Мой коллега, счастливый обладатель MacBook'a, показал мне сногсшибательный эффект сворачивающегося окошка в Mac OS'e: Genie effect (эффект джина). Вот как он выглядит:



Тогда я загорелся идеей сделать аналогичный эффект в Silverlight приложении, но не знал как :). Последнюю неделю я провел за изучением пиксельных шейдеров и к огромной моей радости обнаружил ключ к разгадке. В скором времени родилась библиотека, реализующая Genie-эффект, как в WPF'e, так и в Silverlight'e.  Посмотреть живую демку эффекта можно здесь (нужен 3-й сильверлайт: link). В этом посте мы:
  • Посмотрим как использовать Genie-эффект у себя в коде.
  • Посмотрим как создаются собственные эффекты на базе пиксельных шейдеров.
  • Разберем алгоритм Genie-эффекта
Ссылка на исходники к статье находится в конце поста. Исходники распространяются под LGPL лицензией.

NB: Apple Inc. является обладателем патента на этот эффект в США. Надеюсь, я не нарушаю их прав, описывая в исключительно учебных целях один из возможных вариантов создания подобного эффекта на WPF/Silverlight технологиях.

GenieLib в действии


GenieLib - это моя библиотека, реализующая описанный эффект Джина (исходники - в конце поста). Для того, чтобы создать Genie-эффект в вашем приложении достаточно добавить ссылку на сборку GenieLib и определить два UIElement'a: один будет представлять Джина, а второй - Лампу (место, куда прячется Джин).

Вот, как это выглядит в коде:

XAML:
<Button Click="ToggleGenie"
 x:Name="btnLamp"
        Content="Lamp"/>
<Image x:Name="imgGenie" Source="...">
<Image.RenderTransform>
    <TranslateTransform />
</Image.RenderTransform>
</Image>

C#
private void ToggleGenie()
{
  GenieManager.IsGenieOut = !GenieManager.IsGenieOut;
}

private Magic GenieManager
{
  get
  {
    if (_genieManager == null)
    {
      _genieManager = new Magic(btnLamp, imgGenie);
      _genieManager.Expanding +=
        (o, e) =>
        {
          imgGenie.Visibility = Visibility.Visible;
        };

      _genieManager.Collapsed +=
        (o, e) =>
        {
          imgGenie.Visibility = Visibility.Collapsed;
        };
    }
    return _genieManager;
  }
}

Как видите, мы определили два UIElement'a в xaml-файле, и по нажатию на кнопку, которая по совместительству подрабатывает волшебной лампой, мы обращаемся к объекту типа Magic, инвертируя текущее состояние Джина. Не смотря на название, класс Магии не делает ничего таинственного. В его обязанности входит построение анимации перехода Джина в Лампу и из нее. Класс не накладывает никаких ограничений на представителей Лампы и Джина. Но для правильной работы он ожидает от Джина наличия одного RenderTransform'a: TranslateTransform. Именно этот трансформ позволяет Джину "летать" в пространстве по направлению к лампе и от нее. Если это требование не соблюдается, Джин будет уменьшаться в рамках собственной области, не приближаясь к лампе (картинка слева):



Размер и позиция Лампы относительно Джина играют важную роль. Позиция определяет в какую сторону будет лететь Джин при сворачивании, а размер Лампы (ширина или высота, в зависимости от направления сворачивания) указывает Джину насколько сильно нужно сузиться, чтобы "влезть" в горлышко Лампы :).

Помимо этого класс Magic уведомляет клиентов  при помощи событий о специфических состояниях Джина (собирается спрятаться в лампу, спрятался, собирается выйти из лампы и вышел из лампы). Клиенты могут реагировать на события должным образом. В нашем случае, мы убираем Джина, устанавливая его свойство Visibility в Visibility.Collapsed, когда Джин спрятался в Лампу, и показываем его, когда Джин собирается наружу. Забегая вперед, скажу, что эффект, который реализует уменьшение Джина ничего не знает о HitTesting'e, поэтому мы должны позаботиться об этом самостоятельно. Иными словами, если бы мы не меняли видимость Джина, пользователь все так же мог бы кликнуть мышью по пустой области и попасть на невидимую кнопку спрятавшегося Джина.

Библиотека также поддерживает несколько "продвинутых" сценариев, но я не хочу утомлять вас, дорогой читатель, этими подробностями. Любопытные всегда смогут найти детали в коде, который я старался тщательно документировать и писать предельно ясно :). Дальше мы переходим к промежуточному шагу: создание эффектов при помощи пиксельных шейдеров.


Создание эффектов при помощи шейдеров


Третий Silverlight ввел поддержку эффектов, которые сыздавна были доступны разработчикам WPF-приложений. Теперь мы можем добавить любому  UIElement'у эффект тени (DropShadowEffect), или сделать его размытым (BlurEffect). Но как быть, если мы хотим создать свой собственный эффект, например, полностью убирающий красную составляющую из UIElement'a к которому он прикреплен? На помощь приходит класс ShaderEffect, который также был доступен WPF разработчикам с выходом WPF 3.5 SP1.

Суть ShaderEffect'a проста: взять визуальное представление элемента и прогнать его через пиксельный шейдер. Мое наивное определение пиксельного шейдера: это функция, возвращающая цвет точки в заданной координате. Правда, функция эта описывается не на C#, а на языке шейдеров, и исполняется обычно не на CPU а на GPU. Мы пользуемся С-подобным языком от Microsfot'a: HLSL (High Level Shaders Language - высокоуровневым языком шейдеров) для описания шейдера. В Silvelright 3 Beta 1 сам шейдер выполняется центральным процессором, не смотря на введенную поддержку GPU. В релиз версию парни из Майкрософт обещают включить поддержку выполнения шейдеров видео картой.

Алгоритм создания ShaderEffect'a:

1. Создаем файл с названием RemoveRed.fx. В файле определяем функцию шейдера:
float r : register(C0);

sampler2D implicitInputSampler : register(S0);

float4 main(float2 uv : TEXCOORD) : COLOR
{
   float4 c = tex2D( implicitInputSampler, uv );
   c.r = r;
   return c;
} 
Здесь мы определили две глобальные переменные (r типа float; implicitInputSampler типа sampler2D) и одну функцию main(). На входе функция принимает переменную типа float2 - двумерный вектор, представляющий координаты точки, для которой требуется вычислить цвет. Выходом из функции служит переменная типа float4 - вектор с четырьмя измерениями, описывающий цвет точки (r, g, b, a). Значения координаты точки меняется в пределах от 0 до 1. Точка с координатами (0,0) - верхний левый угол, (1, 1) - нижний правый. Аналогичным диапазоном измеряется и составляющие цвета: от нуля до единицы.

Переменная типа sampler2D предоставляет механизм доступа к цветам элемента, к которому мы применяем эффект. Для того чтобы узнать какого цвета точка, скажем в правом верхнем углу достаточно вызвать встроенную функцию tex2D(implicitInputSampler, (1, 0)), которая вернет нам вектор со значениями цвета: (r, g, b, a). В примере выше, мы просто получаем истинный цвет точки в своей собственной позиции и изменяем красную составляющую на значение глобальной переменной r. Так, если значение r будет равно нулю мы получим следующее преобразование:


Значения глобальных переменных мы можем установить извне шейдера. Как именно - описано ниже. На этом мы закончим обзор HLSL и двинемся дальше, но пытливый читатель всегда найдет много полезной и интересной информации в разделах MSDN'a, посвященных языку программирования шейдеров.

2. Далее, мы компилируем эффект при помощи утилиты fxc.exe из DirectX SDK.. Просто запускаем компилятор из командной строки:

   fxc.exe /T ps_2_0 /E main /Fo RemoveRed.ps RemoveRed.fx
Здесь мы явно указываем компилятору использовать вторую модель пиксельных шейдеров (/T ps_2_0), в качестве входа в программу шейдера служит функция main (/E main), и, ожидаемым результатом компиляции файла RemoveRed.fx является объектный файл RemoveRed.ps (/Fo RemoveRed.ps RemoveRed.fx). Если у вас нет желания устанавливать дополнительно ПО, всегда можно воспользоваться онлайн компилятором:

3. Добавляем скомпилированный файл шейдеров в наш проект. Устанавливаем его Build Action в Resource:


4.Теперь создаем C# класс, который будет представлять наш шейдер в качестве эффекта:

using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Effects;

namespace SilverShaderExample
{
 public class RemoveRedEffect : ShaderEffect
 {
  public static DependencyProperty InputProperty =
   RegisterPixelShaderSamplerProperty("Input",
                                      typeof(RemoveRedEffect), 0);

  public static DependencyProperty RProperty =
   DependencyProperty.Register("R",
                               typeof(double),
                               typeof(RemoveRedEffect),
                               new PropertyMetadata(new double(), PixelShaderConstantCallback(0))
    );

  public RemoveRedEffect()
  {
   var u = new Uri(@"SilverShaderExample;Component/RemoveRed.ps", UriKind.Relative);
   PixelShader = new PixelShader { UriSource = u };

   // Инициализируем свойства, чтобы они получили верные начальные значения.
   UpdateShaderValue(InputProperty);
   UpdateShaderValue(RProperty);
  }

  public virtual Brush Input
  {
   get
   {
    return ((Brush)(GetValue(InputProperty)));
   }
   set
   {
    SetValue(InputProperty, value);
   }
  }

  public virtual double R
  {
   get
   {
    return ((double)(GetValue(RProperty)));
   }
   set
   {
    SetValue(RProperty, value);
   }
  }
 }
}
  

Здесь основной акцент стоит сделать на конструкторе класса и на регистрации Dependency свойств.

Давайте на секунду обратимся к .fx файлу. Помните наши глобальные переменные в шейдере? Так вот, при помощи Dependency-свойств, именно здесь происходит связывание переменных шейдера со свойствами managed-объекта. Сэмплер регистрируется при помощи метода RegisterPixelShaderSamplerProperty(), а переменная R использует PixelShaderConstantCallback() в качестве метода, реагирующего на изменения.

В конструкторе класса мы создаем ссылку на скомпилированный шейдер. Стоит быть очень внимательным в формировании этой ссылки. Например, если вы укажете Uri("RemoveRed.ps", UriKind.Relative) вместо Uri(@"SilverShaderExample;Component/RemoveRed.ps", UriKind.Relative), то сильверлайт упадет с далеко не очевидным объяснением исключения. Затем  мы создаем новый экземпляр класса PixelShader, который представляет собой управляемую обертку над нашим шейдером.

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

Дальше дело за малым: применить эффект можно к любому UIElement'у. Так, следующий код, определяющий Grid с красным фоном будет отображен как черный прямоугольник, благодаря нашему  эффекту:

RemoveRed
В Silverlight 3.0 beta 1 можно применить только один эффект к одному элементу. Но элемент всегда можно вложить в несколько, скажем, Border'ов, и применить по одному из эффектов к каждому Border'у :).

Завершая наш обзор шейдерных эффектов, привожу ссылку на инструмент, значительно упрощающий работу: Shazzam Tool (click-once installer), Shazzam Help (Silverlight-based help). Shazzam позволяет отредактировать HLSL код:



Скомпилировать HLSL-код, посмотреть на результат и на сгенерированный C#- или VB-код для эффекта, не отходя от кассы (WYSIWYG, по-нашему):


Более того, если эффект содержит входящие параметры, их можно модифицировать прямо из Shazzam'a:

Вобщем, must-have, если вы серьезно настроены на создание своих эффектов :).

Алгоритм Genie


Здесь мы посмотрим как заставить Джина "свернуться" вниз. Сворачивание вверх, влево и вправо получается аналогично.

В основе эффекта лежит простая идея: за время t нужно свернуть картинку в точку (а точнее, в отрезок). При t = 0 картинка полностью развернута, а при t = 1 - полностью свернута. Как вы помните, функция пиксельного шейдера возвращает значение цвета точки. Т.е.нам нужно определить цвет заданной точки, с учетом текущего времени t. Это приводит нас к следующему коду:

float t : register(c0); // Время

sampler2D implicitInput : register(s0); // сэмплер

float4 main(float2 uv : TEXCOORD) : COLOR
{
 // [.. вычисление x1, y1 x2, y2 ..]
 
 if (uv.x < x1 || uv.x > x2 || uv.y < y1 || uv.y >y2)
 {
  return float4(0, 0, 0, 0);
 }
  
 float2 pos = float2((uv.x - x1)/(x2 - x1), (uv.y - y1)/(y2 - y1));
  
 return tex2D(implicitInput, pos);
}

Мы проверяем, или выходит текущая точка uv за пределы области (x1, y1, x2, y2). Если да - возвращаем прозрачный цвет (r = 0, g = 0, b = 0, a = 0), если же нет, то мы должны вернуть такой цвет, чтобы создался эффект "сжатия". Для этого мы отображаем ограничивающую нас область на всю картинку, т.е. (x1, x2) -> (0, 1), и (y1, y2) -> (0, 1). Что дает нам необходимые координаты: x = (uv.x - x1)/(x2 - x1), y = (uv.y - y1)/(y2 - y1).

Осталось посчитать x1, x2, y1, y2. Начальные значения переменных устанавливаются таким образом, чтобы вся картинка была видимой: x1 = 0, y1 = 0, x2 = 1, y2 = 1.Теперь мы вводим зависимость этих переменных от времени. Если, например, y1 будет увеличиваться пропорционально времени t: y1 = t, мы получим сжатие по вертикали:


Устанавливая более сложные зависимости, мы получаем Genie-эффект. Эффект состоит из двух фаз. Фаза сжатия по оси Х длится 40% времени:

Фаза сжатия по оси Y (оставшиеся 60%):

Для вычисления x1 и x2  я воспользовался кубическими кривыми Бизье. Одна кривая определяется четырьмя опорными точками P0, P1, P2 и P3. Кривая начинается в P0 и заканчивается в P3. Внутренние точки P1 и P2 управляют формой кривой и определяют начальный и конечный векторы касательных. Другими словами, кривая Бизье третьего порядка начинается в точке P0 направляясь к точке P1 и заканчивается в P3, подходя к ней со стороны точки P2:

Для нас началом кривой всегда является P0 = 0, а конец зависит от времени t, центра лампы и ширины ее "горлышка".  С учетом двухфазового подхода к эффекту, мы получаем следующий HLSL код:
float t : register(c0); // Время
float lampPosition : register(c1); // Центр Лампы
float lampWidth: register(c2); // Ширина Горлышка Лампы

sampler2D implicitInput : register(s0); // Сэмплер

// Параметрическая запись кубической кривой Бизье
// честно стибрена с http://ru.wikipedia.org/wiki/Кривая_Безье
float BezierInterpolate(float4 cp, float t)
{
     float b0 = pow(1-t, 3);
     float b1 = 3*t*pow(1-t,2);
     float b2 = 3*t*t*(1-t);
     float b3 = pow(t, 3);
     return b0*cp[0] + b1*cp[1] + b2*cp[2] + b3*cp[3];
}

float4 main(float2 uv : TEXCOORD) : COLOR
{
     float x1=0, x2=1, y1=0, y2 = 1;
     float4 lp, rp; // Опорные точки левой и правой кривых Бизье
     
     // Фаза сжатия по оси Y
     if (t > 0.4) 
     {
      lp[3] = lampPosition - lampWidth/2;
      rp[3] = lampPosition + lampWidth/2;
      y1 = (5*t - 2)/3.0;
     }
     else // Сжимаемся по оси X.
     {
      lp[3] = t*(lampPosition - lampWidth/2)/0.4;
      rp[3] = 1 + t*(lampPosition + lampWidth/2 - 1.0)/0.4;
      y1 = 0;
     }
     
     // Подсчитываем значения осташихся опорных точек
     // кривых Бизье.
     lp[2] = 0.90*lp[3];
     lp[1] = 0.25*lp[3];
     lp[0] = 0;
     
     rp[2] = 1.25*rp[3];
     rp[1] = 1-(rp[3] - rp[2] - lampWidth/2);
     rp[0] = 1;
     
     lp = saturate(lp); 
     rp = saturate(rp);

     // Вычисляем значение границ по оси X.
     x1 = BezierInterpolate(lp, uv.y);
     x2 = BezierInterpolate(rp, uv.y);
     
     if (uv.x < x1 || uv.x > x2 || uv.y < y1 || uv.y >y2)
     {
          return float4(0, 0, 0, 0);
     }
     
     float2 pos = float2((uv.x - x1)/(x2 - x1), (uv.y - y1)/(y2 - y1));
     
     return tex2D(implicitInput, pos);
}

Единственной темной лошадкой остался вызов HLSL-функции saturate(), применительно к опорным точкам кривых Бизье. Суть этого метода в отбрасывании точек за границей отрезка [0, 1]:
function saturate(x)
{
  if (x < 0) return 0;
  if (x > 1) return 1;
  return x;
}

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

Вот, собственно, и вся магия :).


Что Дальше?


Итак, вы дочитали до самого конца. Спасибо, мне очень приятно :)! Напоследок могу предложить пару ресурсов, продолжающих тему эффектов и шейдеров:

Код к статье можно скачать здесь. Буду рад вашим комментариям.

Отличного настроения :)!

8 комментариев:

  1. Андрюха, тебе не говорили, что ты SL-маньяк? ;) Классная штука! Вот только посмотреть пример мне так и не удалось - браузеры постоянно норовят поставить SL2, а не SL3 beta. И потом ничего не показывают :)

    ОтветитьУдалить
  2. Хмм... Странно. Тестил на виртуалке. У меня браузеры редиректили на страничку Coming Soon, и там есть линк на Welcome to Silverlight 3. А уже там, справа, есть топик "INSTALL SILVERLIGHT 3 BETA", с прямым линком на рантайм Silverlight 3 Beta - Windows Developer RuntimeНадеюсь, удастся посмотреть :).

    ОтветитьУдалить
  3. Получилось, спасибо! :) Здорово работает!

    ОтветитьУдалить
  4. Привет!
    Я тут портировал genie на флэш, посмотреть можно
    здесь.

    ОтветитьУдалить
  5. здрастуйте, этот эффект на виндоус? или для фото видео? ну типа монтажа??

    ОтветитьУдалить