Рисование на поверхностях SurfaceImageSource в WinRT

61

Библиотека DirectXWrapper содержит еще один класс. Я назвал его SurfaceImageSourceRenderer, а архитектурный метод, использованный в его реализации, достаточно сильно отличается от классов библиотеки, служащих обертками для классов DirectWrite. Класс SurfaceImageSourceRenderer создает целый набор объектов DirectX и использует их для предоставления высокоуровневого интерфейса рисования линий на объектах типа SurfaceImageSource.

Класс SurfaceImageSource является производным от ImageSource, а следовательно, может задаваться свойству Source объекта Image или свойству ImageSource объекта ImageBrush. Фактически это растровое изображение, однако для вывода графики (или текста) на этом изображении можно использовать DirectX. Класс SurfaceImageSourceRenderer выполняет всю необходимую вспомогательную работу и предоставляет три открытых метода: Clear, DrawLine и Update. Конечно, этот класс можно доработать, значительно расширив его функциональность.

Заголовочный файл выглядит так:

#pragma once

namespace DirectXWrapper
{
    public ref class SurfaceImageSourceRenderer sealed
    {
    private:
        int width, height;
        Microsoft::WRL::ComPtr<ID2D1Factory> pFactory;
        Microsoft::WRL::ComPtr<ID3D11Device> pd3dDevice;
        Microsoft::WRL::ComPtr<ID3D11DeviceContext> pd3dContext;
        Microsoft::WRL::ComPtr<ISurfaceImageSourceNative> sisNative;
        Microsoft::WRL::ComPtr<IDXGIDevice> pDxgiDevice;
        Microsoft::WRL::ComPtr<ID2D1BitmapRenderTarget> bitmapRenderTarget;
        Microsoft::WRL::ComPtr<ID2D1Bitmap> bitmap;
        Microsoft::WRL::ComPtr<ID2D1SolidColorBrush> solidColorBrush;
        Microsoft::WRL::ComPtr<ID2D1StrokeStyle> strokeStyle;
        bool needsUpdate;

    public:
        SurfaceImageSourceRenderer(
                Windows::UI::Xaml::Media::Imaging::SurfaceImageSource^ surfaceImageSource, 
                int width, int height);
        void Clear(Windows::UI::Color color);
        void DrawLine(Windows::Foundation::Point pt1, Windows::Foundation::Point pt2, 
                      Windows::UI::Color color, double thickness);
        void Update();

    private:
        ID2D1RenderTarget * CreateRenderTarget(Microsoft::WRL::ComPtr<IDXGISurface> pSurface);
        D2D1::ColorF ConvertColor(Windows::UI::Color color);
    };
}

Открытому конструктору требуется экземпляр класса SurfaceImageSource, который является типом Windows Runtime, определенным в пространстве имен Windows.UI.Xaml.Media.Imaging. Конструктор содержит код, для написания которого «с нуля» необходимо быть экспертом по DirectX. Я таковым безусловно не являюсь, поэтому большая часть кода была позаимствована из других учебных проектов, демонстрирующих использование SurfaceImageSource:

#include "pch.h"
#include "SurfaceImageSourceRenderer.h"

using namespace DirectXWrapper;

using namespace Microsoft::WRL;
using namespace Platform;
using namespace Windows::Foundation;
using namespace Windows::UI;
using namespace Windows::UI::Xaml::Media::Imaging;

SurfaceImageSourceRenderer::SurfaceImageSourceRenderer(SurfaceImageSource^ surfaceImageSource, 
                                                       int width, int height)
{
    // Сохранить ширину и высоту изображения
    this->width = width;
    this->height = height;

    // Создать фабрику
    D2D1_FACTORY_OPTIONS options = { D2D1_DEBUG_LEVEL_NONE };

    HRESULT hr = D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED,
                                   __uuidof(ID2D1Factory),
                                   &options,
                                   &pFactory);    
    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);

    // Создание объекта ISurfaceImageSourceNative
    IInspectable* sisInspectable = (IInspectable*) 
                                        reinterpret_cast<IInspectable*>(surfaceImageSource);
    sisInspectable->QueryInterface(__uuidof(ISurfaceImageSourceNative), (void **)&sisNative);

    // Создание устройства и контекста устройства
    D3D_FEATURE_LEVEL featureLevels[] = 
    {
        D3D_FEATURE_LEVEL_11_1,
        D3D_FEATURE_LEVEL_11_0,
        D3D_FEATURE_LEVEL_10_1,
        D3D_FEATURE_LEVEL_10_0,
        D3D_FEATURE_LEVEL_9_3,
        D3D_FEATURE_LEVEL_9_2,
        D3D_FEATURE_LEVEL_9_1,
    };

    hr = D3D11CreateDevice(nullptr,
                           D3D_DRIVER_TYPE_HARDWARE,
                           0,
                           D3D11_CREATE_DEVICE_SINGLETHREADED | 
                                    D3D11_CREATE_DEVICE_BGRA_SUPPORT,
                           featureLevels,
                           ARRAYSIZE(featureLevels),
                           D3D11_SDK_VERSION,
                           &pd3dDevice,
                           nullptr,
                           &pd3dContext);

    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);

    // Получение DXGIDevice
    hr = pd3dDevice.As(&pDxgiDevice);

    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);

    sisNative->SetDevice(pDxgiDevice.Get());

    // Начало рисования
    RECT update = { 0, 0, width, height };
    POINT offset;
    IDXGISurface * dxgiSurface;
    hr = sisNative->BeginDraw(update, &dxgiSurface, &offset);

    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);

    ID2D1RenderTarget * pRenderTarget = CreateRenderTarget(dxgiSurface);

    // Ограничиться созданием совместимого объекта BitmapRenderTarget
    // и получение Bitmap для обновления поверхности
    pRenderTarget->CreateCompatibleRenderTarget(&bitmapRenderTarget);
    bitmapRenderTarget->GetBitmap(&bitmap);

    // Завершение рисования
    sisNative->EndDraw();
    pRenderTarget->Release();
    dxgiSurface->Release();

    // Создание объекта SolidColorBrush для рисования линий
    bitmapRenderTarget->CreateSolidColorBrush(D2D1::ColorF(0, 0, 0, 0), &solidColorBrush);

    // Создание объекта StrokeStyle для рисования линий
    D2D1_STROKE_STYLE_PROPERTIES strokeStyleProperties = 
    {
        D2D1_CAP_STYLE_ROUND,
        D2D1_CAP_STYLE_ROUND,
        D2D1_CAP_STYLE_ROUND,
        D2D1_LINE_JOIN_ROUND,
        10,
        D2D1_DASH_STYLE_SOLID,
        0
    };

    hr = pFactory->CreateStrokeStyle(&strokeStyleProperties, nullptr, 0, &strokeStyle);

    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);
}

// ...

Конструктор использует закрытый метод, который также участвует в операциях рисования. Этот метод создает объект типа ID2D1BitmapRenderTarget, на котором фактически выполняются операции рисования:

ID2D1RenderTarget * SurfaceImageSourceRenderer::CreateRenderTarget(ComPtr<IDXGISurface> pSurface)
{
    D2D1_PIXEL_FORMAT format = 
    {
        DXGI_FORMAT_UNKNOWN,
        D2D1_ALPHA_MODE_PREMULTIPLIED
    };

    float dpiX, dpiY;
    pFactory->GetDesktopDpi(&dpiX, &dpiY);

    D2D1_RENDER_TARGET_PROPERTIES properties = 
    {
        D2D1_RENDER_TARGET_TYPE_DEFAULT,
        format,
        dpiX,
        dpiY,
        D2D1_RENDER_TARGET_USAGE_NONE,
        D2D1_FEATURE_LEVEL_DEFAULT
    };

    ID2D1RenderTarget * pRenderTarget;
    HRESULT hr = pFactory->CreateDxgiSurfaceRenderTarget(pSurface.Get(), 
                                                &properties, &pRenderTarget);

    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);

    return pRenderTarget;
}

Обратите внимание: в конструкторе и методе определяются два указатели на объекты DirectX (dxgiSurface и pRenderTarget), для которых я не использовал ComPtr. Это объясняется тем, что я использую эти объекты в течение очень короткого времени, а затем «вручную» освобождаю их вызовами метода Release.

Метод Clear вызывает метод Clear объекта ID2D1BitmapRenderTarget, сохраненного в поле:

void SurfaceImageSourceRenderer::Clear(Color color)
{
    bitmapRenderTarget->BeginDraw();
    bitmapRenderTarget->Clear(ConvertColor(color));
    bitmapRenderTarget->EndDraw();
    needsUpdate = true;
}

Так как метод является открытым, его аргумент должен быть типом Windows Runtime — так оно и есть. Однако структура Color, определенная в Windows Runtime, не совпадает с цветовыми структурами DirectX, а это означает; что цвет необходимо преобразовать в следующем закрытом методе:

D2D1::ColorF SurfaceImageSourceRenderer::ConvertColor(Color color)
{
    D2D1::ColorF colorf(color.R / 255.0f,
                        color.G / 255.0f,
                        color.B / 255.0f,
                        color.A / 255.0f);
    return colorf;
}

Точки тоже нуждаются в преобразовании. Я определил открытый метод DrawLine для рисования линии между двумя точками, но этот метод начинается с преобразования значений Windows Runtime Point в значения DirectX D2D1_POINT_2F для передачи их методу DrawLine объекта ID2D1BitmapRenderTarget:

void SurfaceImageSourceRenderer::DrawLine(Point point1, Point point2, 
                                          Color color, double thickness)
{
    // Преобразование точек
    D2D1_POINT_2F pt1 = { (float)point1.X, (float)point1.Y };
    D2D1_POINT_2F pt2 = { (float)point2.X, (float)point2.Y };

    // Преобразование цвета для SolidColorBrush
    solidColorBrush->SetColor(ConvertColor(color));

    // Рисование линии
    bitmapRenderTarget->BeginDraw();
    bitmapRenderTarget->DrawLine(pt1, pt2, solidColorBrush.Get(), 
                                           (float)thickness, 
                                           strokeStyle.Get());
    bitmapRenderTarget->EndDraw();
    needsUpdate = true;
}

Разумеется, интерфейс ID2D1BitmapRenderTarget определяет много других методов, помимо DrawLine, но если вы планируете широко использовать эти методы в своем приложении, вам стоит подумать о переносе (хотя бы частичном) кода приложения на C++.

Методы Clear и DrawLine рисуют на объекте ID2D1BitmapRenderTarget; объект SurfaceImageSource должен обновляться по результатам операции. Это происходит в методе Update:

void SurfaceImageSourceRenderer::Update()
{
    // Проверка необходимости обновления
    if (!needsUpdate)
        return;

    needsUpdate = false;

    // Начало рисования
    RECT update = { 0, 0, width, height };
    POINT offset;
    IDXGISurface * dxgiSurface;
    HRESULT hr = sisNative->BeginDraw(update, &dxgiSurface, &offset);

    if (!SUCCEEDED(hr))
        throw ref new COMException(hr);

    ID2D1RenderTarget * renderTarget = CreateRenderTarget(dxgiSurface);
    renderTarget->BeginDraw();

    // Вывод растрового изображения на поверхность
    D2D1_RECT_F rect = { 0, 0, (float)width, (float)height };
    renderTarget->DrawBitmap(bitmap.Get(), &rect);

    // Завершение рисования
    renderTarget->EndDraw();
    sisNative->EndDraw();

    // Освобождение ресурсов
    renderTarget->Release();
    dxgiSurface->Release();
}

На этом наше рассмотрение исходного кода SurfaceImageSourceRenderer подходит к концу. Использование этого класса продемонстрировано в проекте SpinPaint. Программа отображает вращающийся диск, на котором можно рисовать, просто удерживая палец у экрана или перемещая его. Рисунок троекратно дублируется в зеркальном отражении, что позволяет создать интересный узор с минимальными усилиями.

Рисование на вращающейся поверхности

Я написал первую версию SpinPaint для компьютеров размером с журнальный столик, теперь называемых Microsoft PixelSense. Затем программа была портировама для Silverlight с использованием WriteableBitmap и для Windows Phone 7 с использованием XNA. В версии SpinPaint для Windows 8 файл XAML определяет панель Grid с именем referenceGridl, находящуюся в центре страницы. Во время обработки события Loaded панели назначаются такие же размеры, как у объекта SurfaceImageSource, также создаваемого программой. На панели Grid находится другая панель Grid с именем rotatingGrid — вращающаяся, как подсказывает ее имя. Фон внутренней панели Grid просто помогает пользователю понять, что на экране что-то вращается, прежде чем браться за рисование. Поверх нее размещается элемент image для отображения растрового изображения SurfaceImageSource и круга отсечения:

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Grid Name="referenceGrid" 
              Margin="22"
              HorizontalAlignment="Center"
              VerticalAlignment="Center">

            <Grid Name="rotatingGrid">
                <Grid.RenderTransform>
                    <RotateTransform x:Name="rotate" />
                </Grid.RenderTransform>

                <Ellipse>
                    <Ellipse.Fill>
                        <LinearGradientBrush>
                            <GradientStop Offset="1" Color="White" />
                            <GradientStop Offset="0" Color="Black" />
                        </LinearGradientBrush>
                    </Ellipse.Fill>
                </Ellipse>

                <Image Name="image" Stretch="None" />

                <!-- Накрывается все, кроме круга (простейшее отсечение) -->
                <Path Fill="{StaticResource ApplicationPageBackgroundThemeBrush}" Stretch="Uniform">
                    <Path.Data>
                        <GeometryGroup>
                            <RectangleGeometry Rect="0 0 100 100" />
                            <EllipseGeometry Center="50 50" RadiusX="50" RadiusY="50" />
                        </GeometryGroup>
                    </Path.Data>
                </Path>
            </Grid>
        </Grid>

        <TextBlock Name="pageTitle"  FontSize="50"
                   Margin="22" Text="Spin Paint">
            <TextBlock.Foreground>
                <SolidColorBrush />
            </TextBlock.Foreground>
        </TextBlock>

        <Button Name="clearBtn" Content="очистить"
                HorizontalAlignment="Right"
                VerticalAlignment="Bottom"
                FontSize="50"
                Margin="22"
                Click="clearBtn_Click" />
    </Grid>
</Page>

Обработчик Loaded определяет размер объекта SurfaceImageSource основании размеров экрана и состояния просмотра. Он также создает объект SurfaceImageSourceRenderer из библиотеки DirectXWrapper:

using DirectXWrapper;
using System;
using System.Collections.Generic;
using Windows.Foundation;
using Windows.UI;
using Windows.UI.ViewManagement;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;
using Windows.UI.Xaml.Media;
using Windows.UI.Xaml.Media.Imaging;

namespace WinRTTestApp
{
    // ...

    public sealed partial class MainPage : Page
    {
        int dimension;
        RotateTransform inverseRotate = new RotateTransform();
        SurfaceImageSourceRenderer surfaceImageSourceRenderer;

        public MainPage()
        {
            this.InitializeComponent();
            Loaded += MainPage_loader;
        }

        private void MainPage_loader(object sender, RoutedEventArgs e)
        {
            // Определение размеров квадратного изображения
            if (ApplicationView.Value == ApplicationViewState.FullScreenPortrait)
            {
                dimension = (int)(this.ActualWidth - referenceGrid.Margin.Left
                                                   - referenceGrid.Margin.Right);
            }
            else
            {
                dimension = (int)(this.ActualHeight - referenceGrid.Margin.Top
                                                    - referenceGrid.Margin.Bottom);
            }

            // Полученный размер назначается панели referenceGrid,
            // чтобы она не искажалась в режиме Snapped
            referenceGrid.Width = dimension;
            referenceGrid.Height = dimension;

            // Создание объектов SurfaceImageSource и SurfaceImageSourceRenderer
            SurfaceImageSource surfaceImageSource = new SurfaceImageSource(dimension, dimension);
            surfaceImageSourceRenderer = new SurfaceImageSourceRenderer(surfaceImageSource,
                                                                        dimension, dimension);
            image.Source = surfaceImageSource;

            // Назначение центров вращения
            rotate.CenterX = dimension / 2;
            rotate.CenterY = dimension / 2;

            inverseRotate.CenterX = dimension / 2;
            inverseRotate.CenterY = dimension / 2;

            // Отслеживание события
            CompositionTarget.Rendering += OnCompositionTargetRendering;
        }

        private void clearBtn_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
        {
            SurfaceImageSource surfaceImageSource = new SurfaceImageSource(dimension, dimension);
            surfaceImageSourceRenderer = new SurfaceImageSourceRenderer(surfaceImageSource,
                                                                        dimension, dimension);
            image.Source = surfaceImageSource;
        }
        
        // ...
    }
}

Кнопка "очистить" просто создает новый объект SurfaceImageSource и SurfaceImageSourceRenderer предопределенного размера.

Как и в программах серии FingerPaint, в SpinPaint можно рисовать несколькими пальцами. Однако концептуально вы рисуете на вращающемся диске, поэтому для рисования достаточно прижать палец к экрану. Иначе говоря, ваш палец может рисовать без перемещения и без генерирования событий PointerMoved!

Это требует несколько иного подхода к обработке событий Pointer. Конечно, в программе по-прежнему имеется словарь, а содержащиеся в нем значения FingerInfo содержат поля LastPosition и ThisPosition. Однако в переопределении OnPointerPressed поле LastPosition инициализируется бесконечными координатами, а в OnPointerPressed и OnPointerMoved полям ThisPosition задается текущее положение пальца. Кроме инициализации поля LastPosition, эти переопределения никогда не задают этому полю никакие другие значения:

// ...

namespace WinRTTestApp
{
    public class FingerInfo
    {
        public Point ThisPosition;
        public Point LastPosition;
    }

    public sealed partial class MainPage : Page
    {
        // ...
        
        Dictionary<uint, FingerInfo> fingerTouches = new Dictionary<uint, FingerInfo>();

        protected override void OnPointerPressed(PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;
            Point pt = e.GetCurrentPoint(referenceGrid).Position;

            if (fingerTouches.ContainsKey(id))
                fingerTouches.Remove(id);

            FingerInfo fingerInfo = new FingerInfo
            {
                LastPosition = new Point(Double.PositiveInfinity, Double.PositiveInfinity),
                ThisPosition = pt,
            };

            fingerTouches.Add(id, fingerInfo);
            CapturePointer(e.Pointer);
            base.OnPointerPressed(e);
        }

        protected override void OnPointerMoved(PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;
            Point pt = e.GetCurrentPoint(referenceGrid).Position;

            if (fingerTouches.ContainsKey(id))
                fingerTouches[id].ThisPosition = pt;

            base.OnPointerMoved(e);
        }

        protected override void OnPointerReleased(PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;

            if (fingerTouches.ContainsKey(id))
                fingerTouches.Remove(id);

            base.OnPointerReleased(e);
        }

        protected override void OnPointerCaptureLost(PointerRoutedEventArgs e)
        {
            uint id = e.Pointer.PointerId;

            if (fingerTouches.ContainsKey(id))
                fingerTouches.Remove(id);

            base.OnPointerCaptureLost(e);
        }

        // ...
    }
}

Все действительно интересное происходит в обработчике события CompositionTarget.Rendering. На основании текущего времени приложения вычисляется угол поворота панели Grid с именем rotatingGrid и цвет рисования. Этот цвет также применяется к элементу TextBlock с именем приложения в левом верхнем углу страницы:

// ...

namespace WinRTTestApp
{
    // ...

    public sealed partial class MainPage : Page
    {
        // ...

        private void OnCompositionTargetRendering(object sender, object e)
        {
            // Получение кол-ва секунд от запуска приложения
            TimeSpan timeSpan = (e as RenderingEventArgs).RenderingTime;
            double seconds = timeSpan.TotalSeconds;

            // Вычислить угол поворота
            rotate.Angle = (360 * seconds / 5) % 360;

            // Вычисление цвета и кисти
            Color clr;
            double fraction = 6 * (seconds % 10) / 10;

            if (fraction < 1)
                clr = Color.FromArgb(255, 255, (byte)(fraction * 255), 0);
            else if (fraction < 2)
                clr = Color.FromArgb(255, (byte)(255 - (fraction - 1) * 255), 255, 0);
            else if (fraction < 3)
                clr = Color.FromArgb(255, 0, 255, (byte)((fraction - 2) * 255));
            else if (fraction < 4)
                clr = Color.FromArgb(255, 0, (byte)(255 - (fraction - 3) * 255), 255);
            else if (fraction < 5)
                clr = Color.FromArgb(255, (byte)((fraction - 4) * 255), 0, 255);
            else
                clr = Color.FromArgb(255, 255, 0, (byte)(255 - (fraction - 5) * 255));

            (pageTitle.Foreground as SolidColorBrush).Color = clr;

            // Если касаний нет, больше ничего делать не нужно
            if (fingerTouches.Count == 0)
                return;

            // ...
        }
    }
}

Затем для каждого пальца, касающегося экрана, позиция в поле ThisPosition объекта FingerInfo поворачивается так, что точка из экранных координат переходит в координаты относительно вращающегося элемента Image. Полученная точка в совокупности с полем LastPosition объекта FingerInfo используется для рисования. Четыре вызова метода DrawLine рисуют четыре линии в четырех квадрантах изображения:

// ...

namespace WinRTTestApp
{
    // ...

    public sealed partial class MainPage : Page
    {
        // ...

        private void OnCompositionTargetRendering(object sender, object e)
        {
            // ...

            bool bitmapNeedsUpdate = false;

            foreach (FingerInfo fingerInfo in fingerTouches.Values)
            {
                // Определение точки относительно повернутого изображения
                inverseRotate.Angle = -rotate.Angle;
                Point point1 = inverseRotate.TransformPoint(fingerInfo.ThisPosition);

                if (!Double.IsPositiveInfinity(fingerInfo.LastPosition.X))
                {
                    Point point2 = fingerInfo.LastPosition;
                    float thickness = 12;

                    // Рисование линий
                    surfaceImageSourceRenderer.DrawLine(point1, point2, clr, thickness);
                    surfaceImageSourceRenderer.DrawLine(new Point(dimension - point1.X, point1.Y),
                                                        new Point(dimension - point2.X, point2.Y),
                                                        clr, thickness);
                    surfaceImageSourceRenderer.DrawLine(new Point(point1.X, dimension - point1.Y),
                                                        new Point(point2.X, dimension - point2.Y),
                                                        clr, thickness);
                    surfaceImageSourceRenderer.DrawLine(new Point(dimension - point1.X,
                                                                  dimension - point1.Y),
                                                        new Point(dimension - point2.X,
                                                                  dimension - point2.Y),
                                                        clr, thickness);
                    bitmapNeedsUpdate = true;
                }
                fingerInfo.LastPosition = point1;
            }

            // Обновить изображение
            if (bitmapNeedsUpdate)
            {
                surfaceImageSourceRenderer.Update();
            }
        }
    }
}

Также повернутая позиция пальца сохраняется в поле LastPosition объекта FingerInfo. Собственно, именно так и организуется рисование на экране неподвижным пальцем: даже если палец остался на прежнем месте, текущая позиция пальца была получена в переопределении OnPointerPressed и сохранена в поле ThisPosition объекта FingerInfo. При каждом вызове CompositionTarget.Rendering значение ThisPosition поворачивается с новым углом, и новая точка соединяется с точкой из поля LastPosition. Повернутая позиция сохраняется в поле LastPosition объекта FingerInfo для следующей итерации.

Пройди тесты
Лучший чат для C# программистов