Радиальный градиент в WinRT

189

Среди многих загадочно отсутствующих компонентов 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 чуть больше напоминает объект реального мира:

Эмуляция радиального градиента в Windows Runtime
Пройди тесты
Лучший чат для C# программистов