Радиальный градиент в WinRT
189Разработка под Windows 8/10 --- Радиальный градиент
Среди многих загадочно отсутствующих компонентов Windows Runtime можно отметить кисть RadialGradientBrush, которая обычно используется для градиентной окраски круга из внутренней точки по направлению к периметру. В частности, радиальный градиент часто применяется для превращения круга в трехмерный «шар», словно область в левом верхнем углу отражает свет.
Я начал писать класс RadialGradientBrushSimulator, ориентируясь на анимацию свойства GradientOrigin этого класса в файле XAML. По этой причине я сделал класс RadialGradientBrushSimulator производным от FrameworkElement, несмотря на то, что он сам по себе ничего не выводит. Наследование от FrameworkElement упростило создание экземпляров класса в XAML. А поскольку я думал об анимациях и привязках, все свойства были определены как свойства зависимости. Следующая часть класса не содержит почти ничего, кроме служебного кода свойств зависимости:
using System;
using System.IO;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.UI;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Imaging;
namespace WinRTTestApp
{
public class RadialGradientBrushSimulator : FrameworkElement
{
// ...
static readonly DependencyProperty innerColorProperty =
DependencyProperty.Register("InnerColor",
typeof(Color),
typeof(RadialGradientBrushSimulator),
new PropertyMetadata(Colors.White, OnPropertyChanged));
static readonly DependencyProperty gradientOriginProperty =
DependencyProperty.Register("GradientOrigin",
typeof(Point),
typeof(RadialGradientBrushSimulator),
new PropertyMetadata(new Point(0.5, 0.5), OnPropertyChanged));
static readonly DependencyProperty clipToEllipseProperty =
DependencyProperty.Register("ClipToEllipse",
typeof(bool),
typeof(RadialGradientBrushSimulator),
new PropertyMetadata(false, OnPropertyChanged));
static readonly DependencyProperty outerColorProperty =
DependencyProperty.Register("OuterColor",
typeof(Color),
typeof(RadialGradientBrushSimulator),
new PropertyMetadata(Colors.Black, OnPropertyChanged));
public static DependencyProperty imageSourceProperty =
DependencyProperty.Register("ImageSource",
typeof(ImageSource),
typeof(RadialGradientBrushSimulator),
new PropertyMetadata(null));
public RadialGradientBrushSimulator()
{
SizeChanged += OnSizeChanged;
}
public static DependencyProperty InnerColorProperty
{
get { return innerColorProperty; }
}
public static DependencyProperty GradientOriginProperty
{
get { return gradientOriginProperty; }
}
public static DependencyProperty OuterColorProperty
{
get { return outerColorProperty; }
}
public static DependencyProperty ImageSourceProperty
{
get { return imageSourceProperty; }
}
public static DependencyProperty ClipToEllipseProperty
{
get { return clipToEllipseProperty; }
}
public Color OuterColor
{
set { SetValue(OuterColorProperty, value); }
get { return (Color)GetValue(OuterColorProperty); }
}
public bool ClipToEllipse
{
set { SetValue(ClipToEllipseProperty, value); }
get { return (bool)GetValue(ClipToEllipseProperty); }
}
public Point GradientOrigin
{
set { SetValue(GradientOriginProperty, value); }
get { return (Point)GetValue(GradientOriginProperty); }
}
public Color InnerColor
{
set { SetValue(InnerColorProperty, value); }
get { return (Color)GetValue(InnerColorProperty); }
}
public ImageSource ImageSource
{
private set { SetValue(ImageSourceProperty, value); }
get { return (ImageSource)GetValue(ImageSourceProperty); }
}
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
this.RefreshBitmap();
}
static void OnPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
(obj as RadialGradientBrushSimulator).RefreshBitmap();
}
// ...
}
}
В приведенном ниже методе RefreshBitmap класс использует свойства GradientOrigin, InnerColor, OuterColor и ClipToEllipse (а также свойства ActualWidth и ActualHeight элемента) для создания объекта WriteableBitmap. Класс предоставляет доступ к этому объекту через свойство ImageSource, что позволяет другому элементу в файле XAML ссылаться на него через привязку к свойству ImageSource объекта ImageBrush.
Потом выяснилось, что алгоритм построения радиальной градиентной кисти не так уж тривиален. На концептуальном уровне вы работаете с эллипсом, хотя растровое изображение может использоваться для окраски прямоугольника и вообще чего угодно. Цвет на границе эллипса определяется свойством OuterColor. Свойство GradientOrigin типа Point задается в относительных координатах. Например, значение (0.5,0.5) устанавливает GradientOrigin в центр эллипса. Цвет в точке GradientOrigin задается свойством InnerColor.
Для любой точки (x, y) внутри изображения алгоритм должен определить коэффициент интерполяции для вычисления цвета из диапазона между InnerColor и OuterColor. Коэффициент интерполяции вычисляется по отрезку прямой из точки GradientOrigin через точку (x, y) к контуру эллипса. Пропорция, в которой (x, y) делит этот отрезок, определяет значение коэффициента интерполяции. Чтобы добиться лучшей производительности, я решил обойтись без тригонометрии. Вместо этого моя стратегия была основана на определении точки пересечения контура эллипса с линией, соединяющей GradientOrigin с (x, y). Это требовало решения квадратного уравнения для каждой точки растрового изображения.
Метод RefreshBitmap выглядит так:
// ...
namespace WinRTTestApp
{
public class RadialGradientBrushSimulator : FrameworkElement
{
byte[] pixels;
WriteableBitmap bitmap;
Stream pixelStream;
// ...
private void RefreshBitmap()
{
if (this.ActualWidth == 0 || this.ActualHeight == 0)
{
this.ImageSource = null;
pixelStream = null;
pixels = null;
bitmap = null;
return;
}
if (bitmap == null || (int)this.ActualWidth != bitmap.PixelWidth ||
(int)this.ActualHeight != bitmap.PixelHeight)
{
bitmap = new WriteableBitmap((int)this.ActualWidth, (int)this.ActualHeight);
this.ImageSource = bitmap;
pixels = new byte[4 * bitmap.PixelWidth * bitmap.PixelHeight];
pixelStream = bitmap.PixelBuffer.AsStream();
}
else
{
for (int i = 0; i < pixels.Length; i++)
pixels[i] = 0;
}
double yOrigin = 2 * this.GradientOrigin.Y - 1;
double xOrigin = 2 * this.GradientOrigin.X - 1;
byte aOutsideCircle = 0;
byte rOutsideCircle = 0;
byte gOutsideCircle = 0;
byte bOutsideCircle = 0;
if (!this.ClipToEllipse)
{
double opacity = this.OuterColor.A / 255.0;
aOutsideCircle = this.OuterColor.A;
rOutsideCircle = (byte)(opacity * this.OuterColor.R);
gOutsideCircle = (byte)(opacity * this.OuterColor.G);
gOutsideCircle = (byte)(opacity * this.OuterColor.B);
}
int index = 0;
for (int yPixel = 0; yPixel < bitmap.PixelHeight; yPixel++)
{
// Вычисление y относительно единичного круга
double y = 2.0 * yPixel / bitmap.PixelHeight - 1;
for (int xPixel = 0; xPixel < bitmap.PixelWidth; xPixel++)
{
// Вычисление x относительно единичного круга
double x = 2.0 * xPixel / bitmap.PixelWidth - 1;
// Проверяем, находится ли точка внутри круга
if (x * x + y * y <= 1)
{
// Относительная длина отрезка от центра градиента до точки
double length1 = 0;
// Относительная длина отрезка от точки до единичного круга
// (length1 + length2 = 1)
double length2 = 0;
if (x == xOrigin && y == yOrigin)
{
length2 = 1;
}
else
{
// Известно, что: xCircle^2 + yCircle^2 = 1
double xCircle = 0, yCircle = 0;
if (x == xOrigin)
{
xCircle = x;
yCircle = (y < yOrigin ? -1 : 1) * Math.Sqrt(1 - x * x);
}
else if (y == yOrigin)
{
xCircle = (x < xOrigin ? -1 : 1) * Math.Sqrt(1 - y * y);
yCircle = y;
}
else
{
// Линия от центра градиента до точки выражается в форме y = mx + k
double m = (yOrigin - y) / (xOrigin - x);
double k = y - m * x;
// Представляем (mx + k) вместо y в x^2 + y^2 = 1
// x^2 + (mx + k)^2 = 1
// x^2 + (mx)^2 + 2mxk + k^2 - 1 = 0
// (1 + m^2)x^2 + (2mk)x + (k^2 - 1) = 0 это квадратное уравнение
double a = 1 + m * m;
double b = 2 * m * k;
double c = k * k - 1;
// Теперь решаем для x
double sqrtTerm = Math.Sqrt(b * b - 4 * a * c);
double x1 = (-b + sqrtTerm) / (2 * a);
double x2 = (-b - sqrtTerm) / (2 * a);
if (x < xOrigin)
xCircle = Math.Min(x1, x2);
else
xCircle = Math.Max(x1, x2);
yCircle = m * xCircle + k;
}
// Длина отрезка от центра градиента до точки
length1 = Math.Sqrt(Math.Pow(x - xOrigin, 2) + Math.Pow(y - yOrigin, 2));
// Длина отрезка от точки до контура
length2 = Math.Sqrt(Math.Pow(x - xCircle, 2) + Math.Pow(y - yCircle, 2));
// Нормализация длин
double total = length1 + length2;
length1 /= total;
length2 /= total;
}
// Интерполяция цвета
double alpha = length2 * this.InnerColor.A + length1 * this.OuterColor.A;
double green = alpha * (length2 * this.InnerColor.G +
length1 * this.OuterColor.G) / 255;
double red = alpha * (length2 * this.InnerColor.R +
length1 * this.OuterColor.R) / 255;
double blue = alpha * (length2 * this.InnerColor.B +
length1 * this.OuterColor.B) / 255;
// Сохранить в массив
pixels[index++] = (byte)blue;
pixels[index++] = (byte)green;
pixels[index++] = (byte)red;
pixels[index++] = (byte)alpha;
}
else
{
pixels[index++] = bOutsideCircle;
pixels[index++] = gOutsideCircle;
pixels[index++] = rOutsideCircle;
pixels[index++] = aOutsideCircle;
}
}
}
pixelStream.Seek(0, SeekOrigin.Begin);
pixelStream.Write(pixels, 0, pixels.Length);
bitmap.Invalidate();
}
}
}
Так как программа ориентирована на анимацию, массив пикселов и объект Stream, используемый для передачи пикселов в изображение, сохраняются в полях. В методе RefreshBitmap выделения памяти из кучи не нужны, если только объект WriteableBitmap не потребуется создавать заново из-за изменения размера элемента.
Но производительность анимации оказалась крайне низкой даже с относительно малыми размерами изображения. Но если удастся избежать анимации самого градиента, ничто не мешает применить анимацию к окрашиваемому объекту. Файл MainPage.xaml создает экземпляры RadialGradientBrushSimulator и Ellipse с привязкой к эмулятору кисти, а также пару анимаций:
<Page ...>
<Grid Background="#FF1D1D1D">
<Canvas SizeChanged="Canvas_SizeChanged"
Margin="0 0 94 94">
<Grid Name="ballContainer"
Width="94"
Height="94">
<Ellipse Name="ellipse">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding ElementName=brushSimulator,
Path=ImageSource}" />
</Ellipse.Fill>
</Ellipse>
<local:RadialGradientBrushSimulator x:Name="brushSimulator"
InnerColor="White"
OuterColor="Red"
GradientOrigin="0.3 0.3" />
</Grid>
</Canvas>
</Grid>
<Page.Triggers>
<EventTrigger>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation x:Name="leftAnimation"
From="0" Duration="0:0:2.51"
Storyboard.TargetName="ballContainer"
Storyboard.TargetProperty="(Canvas.Left)"
AutoReverse="True"
RepeatBehavior="Forever" />
<DoubleAnimation x:Name="rightAnimation"
Storyboard.TargetName="ballContainer"
Storyboard.TargetProperty="(Canvas.Top)"
From="0" Duration="0:0:1.01"
AutoReverse="True"
RepeatBehavior="Forever" />
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</Page.Triggers>
</Page>
Обратите внимание на размещение Ellipse и RadialGradientBrushSimulator в одной квадратной панели Grid с размером 94 пикселов, чтобы оба элемента имели одинаковый размер, а размер генерируемого изображения точно соответствовал размеру окрашиваемого объекта Ellipse. Файл фонового кода просто настраивает значения To анимаций в зависимости от размера Canvas:
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
namespace WinRTTestApp
{
public sealed partial class MainPage : Page
{
public MainPage()
{
this.InitializeComponent();
}
private void Canvas_SizeChanged(object sender, SizeChangedEventArgs e)
{
// Анимация Canvas.Left
leftAnimation.To = e.NewSize.Width;
// Анимация Canvas.Top
rightAnimation.To = e.NewSize.Height;
}
}
}
С имитацией отраженного света объект Ellipse чуть больше напоминает объект реального мира: