Введение
Большую часть времени, разработка под Windows Runtime приносит ни с чем не сравнимое удовольствие. Дел-то всего ничего: налепил контролов, добавил щепотку MVVM, а потом сидишь, и любуешься своим кодом. Так происходит в 99% случаев. В оставшейся сотой доле, начинаются настоящие пляски с бубном.
На самом деле, я утрирую, к языческим ритуалам прибегаю только в совсем безвыходных ситуациях. А вот WP разработчикам есть за что поругать MS, начав хотя бы с бедных Silverlight разработчиков, на долю которых пришлись все несчастья. Ну да ладно, это всё уже в оффтопик ушло.
Кэп, ты где?
Итак, мысленно перенесемся в гипотетическую ситуацию. У нас есть приложение, пусть это будет клиент для kinopoisk.ru под Windows 8.1. И постер какого-либо голливудского AAA проекта с многомиллионным бюджетом и супергероями из любимых нами комиксов. Задача – отобразить пользователю постер в идеальном качестве. Под словом «идеальное» я имею в виду соответствие 1 пиксель изображения == 1 пиксель физический.
Казалось бы, пустяк, создаем Image и присваиваем его свойству Source нужный BitmapImage с картинкой. Вот только размер картинки настораживает – 9300 x 12300. Взяв в руки калькулятор, начинаю считать: 9300 * 12300 пикс * 4 Б/пикс = 436 МБ. Довольно внушительная цифра, но в 21 веке такими вещами уже не удивишь. Средний настольник 2010 года без проблем переваривает такие объемы данных, так что жмём F5 и наслаждаемся своим творением. Всё отлично работает, по меньшей мере на моём компьютере, и ладно. На этом статью можно было бы и закончить…
Когда целый дверной проем стал слишком узким для нас
Ну что же, тогда мы мысленно подкорректируем ТЗ. Пусть наш клиент kinopoisk.ru будет новомодным «Universal Application», т.е. одно приложение и для Windows, и для Windows Phone. Отлично, в коде править ничего не пришлось, достаточно было только перекомпиляции. Засучив рукава, запускаю на своей Lumia 920 и… оно сразу падает…
После небольшого загугливания, выяснилось, что на моей люмии (у которой на борту целых 1 ГБ памяти) приложениям доступно всего 390 МБ, в которые наша картинка явно не влезает. Устройства с 2 ГБ – пока что непозволительная, в моём случае, роскошь. Очень жаль, придется искать обходные пути.
И что мне теперь делать?
Скажете вы. Вариантов в общем-то и немного:
- Ужать картинку до приемлемых размеров
- Рисовать только видимую на экране часть
Первый вариант сразу отпадает, жертвовать качеством мы не будем. Так что переходим сразу ко второму. Здесь нас опять поджидает распутье:
- Написать все самому
- VirtualSurfaceImageSource
И тогда я вспомнил, что настоящий программист – это в первую очередь ленивый программист. Так что воспользуемся готовым вариантом, любезно предоставленным нам разработчиками из Редмонда. На самом деле у VirtualSurfaceImageSource есть несколько преимуществ, которых невозможно было бы добиться своими силами связкой XAML+C#, но все эти вкусности оставим на потом.
VirtualSurfaceImageSource – та самая “серебряная пуля”
Итак, вот мы и пришли к «гвоздю» нашей сегодняшней программы. Как я уже отметил ранее, VirtualSurfaceImageSource хранит в памяти только видимую часть изображения. Эта штука здорово выручает в случае, если приложению необходимо отобразить большие объемы данных. С такими приложениями все из нас сталкиваются постоянно: карты (Bing Maps, HERE Maps), PDF Reader (который в Windows 8.1), и даже такие крутые, как Internet Explorer, Word и Excel под Windows Phone используют схожую технологию.
Описание получилось довольно упрощенным, хотя на самом деле логика работы VirtualSurfaceImageSource намного сложнее, а под капотом он совершает очень много вычислений и всяких разных оптимизаций. Нас эти подробности волновать не должны, всё это скучно и неинтересно. Важно только то, что видно нам снаружи.
А для нас всё очень просто. VirtualSurfaceImageSource даёт указания, какие части изображения нужно перерисовать. Всю остальную работу он берет на себя. Как указано на картинке выше, мы рисуем только видимую часть изображения. Считать координаты смещения не нужно, VirtualSurfaceImageSource вычисляет их за нас. Грубо говоря, последовательность наших действий такова:
- Получаем IVirtualSurfaceImageSourceNative
- Подписываемся на отрисовку с помощью RegisterForUpdatesNeeded
- При вызове callback рисуем нужный регион
Выглядит все очень просто, осталось только написать код. Рисовать будем с помощью Direct2D, хотя в моем случае подошло бы банальное копирование памяти в Surface. Чтобы не захламлять solution десятком ненужных нам проектов, я создал C++ Blank App (Universal Application). С появлением C++/CX, взаимодействие с C# кодом сводится к минимуму изменений, так что в данной статье я тактично обойду эту тему. Но если вдруг кому будет интересно, пишите в комментариях, с радостью расскажу!
Шаг 0: Подготовительный
Еще раз повторюсь – в этом примере я создал C++ Blank App (Universal Application). Для простоты весь код будет в code-behind страницы MainPage.
Так как IVirtualSurfaceImageSourceNative не является Windows Runtime интерфейсом, то придется подключить специальный заголовочный файл.
#include <windows.ui.xaml.media.dxinterop.h>
Объявим все необходимые нам поля и методы:
public ref class MainPage sealed
{
public:
MainPage();
void UpdatesNeeded();
private:
// DirectX методы
void CreateDeviceResources();
void CreateDeviceIndependentResources();
void HandleDeviceLost();
// Создает VirtualSurfaceImageSource
// и устанавлиет его в Image
void CreateVSIS();
// Рисует указанный регион
void RenderRegion(const RECT& updateRect);
private:
float dpi;
ComPtr<ID2D1Factory1> d2dFactory;
ComPtr<ID2D1Device> d2dDevice;
ComPtr<ID2D1DeviceContext> d2dDeviceContext;
ComPtr<IDXGIDevice> dxgiDevice;
// Наше изображение
BitmapFrame^ bitmapFrame;
// Ссылка на данный VirtualSurfaceImageSource
VirtualSurfaceImageSource^ vsis;
// Ссылка на IVirtualSurfaceImageSourceNative
ComPtr<IVirtualSurfaceImageSourceNative> vsisNative;
};
И конструктор:
MainPage::MainPage()
{
InitializeComponent();
// Получаем текущий DPI
dpi = DisplayInformation::GetForCurrentView()->LogicalDpi;
CreateDeviceIndependentResources();
CreateDeviceResources();
CreateVSIS();
}
Комментировать здесь особо и не чего, разве что кого-то может смутить ComPtr<T>. Это обычный smart pointer, подобный shared_ptr<T>, только для COM-объектов.
В дальнейшем, я буду использовать такую нехитрую вещь, которая очень пригодится при отладке:
namespace DX
{
inline void ThrowIfFailed(_In_ HRESULT hr)
{
if (FAILED(hr))
{
// Set a breakpoint on this line to catch DX API errors.
throw Platform::Exception::CreateException(hr);
}
}
}
Шаг 1: Рутина инициализации
Здесь ничего интересного, писать такое руками – дело негусарское. Так что я утащил этот код из примеров MS с минимальными изменениями. Комментарии оставлены оригинальные.
// Create device independent resources
void MainPage::CreateDeviceIndependentResources()
{
D2D1_FACTORY_OPTIONS options;
ZeroMemory(&options, sizeof(D2D1_FACTORY_OPTIONS));
#if defined(_DEBUG)
// If the project is in a debug build, enable Direct2D debugging via Direct2D SDK layer.
// Enabling SDK debug layer can help catch coding mistakes such as invalid calls and
// resource leaking that needs to be fixed during the development cycle.
options.debugLevel = D2D1_DEBUG_LEVEL_INFORMATION;
#endif
DX::ThrowIfFailed(
D2D1CreateFactory(
D2D1_FACTORY_TYPE_SINGLE_THREADED,
__uuidof(ID2D1Factory1),
&options,
&d2dFactory
)
);
}
// These are the resources that depend on hardware.
void MainPage::CreateDeviceResources()
{
// This flag adds support for surfaces with a different color channel ordering than the API default.
// It is recommended usage, and is required for compatibility with Direct2D.
UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
// This array defines the set of DirectX hardware feature levels this app will support.
// Note the ordering should be preserved.
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
};
// Create the D3D11 API device object, and get a corresponding context.
ComPtr<ID3D11Device> d3dDevice;
ComPtr<ID3D11DeviceContext> d3dContext;
D3D_FEATURE_LEVEL featureLevel;
DX::ThrowIfFailed(
D3D11CreateDevice(
nullptr, // specify null to use the default adapter
D3D_DRIVER_TYPE_HARDWARE,
0, // leave as 0 unless software device
creationFlags, // optionally set debug and Direct2D compatibility flags
featureLevels, // list of feature levels this app can support
ARRAYSIZE(featureLevels), // number of entries in above list
D3D11_SDK_VERSION, // always set this to D3D11_SDK_VERSION for Modern style apps
&d3dDevice, // returns the Direct3D device created
&featureLevel, // returns feature level of device created
&d3dContext // returns the device immediate context
)
);
// Obtain the underlying DXGI device of the Direct3D11.1 device.
DX::ThrowIfFailed(
d3dDevice.As(&dxgiDevice)
);
// Obtain the Direct2D device for 2-D rendering.
DX::ThrowIfFailed(
d2dFactory->CreateDevice(dxgiDevice.Get(), &d2dDevice)
);
// And get its corresponding device context object.
DX::ThrowIfFailed(
d2dDevice->CreateDeviceContext(
D2D1_DEVICE_CONTEXT_OPTIONS_NONE,
&d2dDeviceContext
)
);
// Since this device context will be used to draw content onto XAML surface image source,
// it needs to operate as pixels. Setting pixel unit mode is a way to tell Direct2D to treat
// the incoming coordinates and vectors, typically as DIPs, as in pixels.
d2dDeviceContext->SetUnitMode(D2D1_UNIT_MODE_PIXELS);
// Despite treating incoming values as pixels, it is still very important to tell Direct2D
// the logical DPI the application operates on. Direct2D uses the DPI value as a hint to
// optimize internal rendering policy such as to determine when is appropriate to enable
// symmetric text rendering modes. Not specifying the appropriate DPI in this case will hurt
// application performance.
d2dDeviceContext->SetDpi(dpi, dpi);
// When an application performs animation or image composition of graphics content, it is important
// to use Direct2D grayscale text rendering mode rather than ClearType. The ClearType technique
// operates on the color channels and not the alpha channel, and therefore unable to correctly perform
// image composition or sub-pixel animation of text. ClearType is still a method of choice when it
// comes to direct rendering of text to the destination surface with no subsequent composition required.
d2dDeviceContext->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
}
Единственное, что стоит отметить – SetUnitMode(). По комментарию, в принципе, все должно быть понятно. Но всё же не забудьте изменить значение на D2D1_UNIT_MODE_DIPS, если будете рисовать Direct2D примитивы или текст. В нашем случае это будет только мешать.
Шаг 2: Создание VirtualSurfaceImageSource
Данная операция сводится всего к 3 действиям:
// Создаем VirtualSurfaceImageSource
// Прозрачность нам не нужна, поэтому isOpaque = false
vsis = ref new VirtualSurfaceImageSource(bitmapFrame->PixelWidth, bitmapFrame->PixelHeight, false);
// Приводим VirtualSurfaceImageSource к IVirtualSurfaceImageSourceNative
DX::ThrowIfFailed(
reinterpret_cast<IInspectable*>(vsis)->QueryInterface(IID_PPV_ARGS(&vsisNative))
);
// Устанавливаем DXGI устройство
DX::ThrowIfFailed(
vsisNative->SetDevice(dxgiDevice.Get())
);
Теперь нам нужно создать callback объект. Для этого объявим новый класс, реализующий IVirtualSurfaceUpdatesCallbackNative:
class VSISCallback : public RuntimeClass < RuntimeClassFlags<ClassicCom>, IVirtualSurfaceUpdatesCallbackNative >
{
public:
HRESULT RuntimeClassInitialize(_In_ WeakReference parameter)
{
reference = parameter;
return S_OK;
}
IFACEMETHODIMP UpdatesNeeded()
{
// Приводим к MainPage^
MainPage^ mainPage = reference.Resolve<MainPage>();
// Если mainPage еще не удален
if (mainPage != nullptr)
{
mainPage->UpdatesNeeded();
}
return S_OK;
}
private:
WeakReference reference;
};
Данный callback будет срабатывать при необходимости перерисовать регион. Наша реализация вызывает MainPage::UpdatesNeeded(), делающую всю грязную работу. WeakReference нужен для предотвращения утечек памяти, в случае, если мы перешли на другую страницу, но забыли отписать наш callback.
Осталось только зарегистрировать данный callback:
// Создаем экземпляр VSISCallBack
WeakReference parameter(this);
ComPtr<VSISCallback> callback;
DX::ThrowIfFailed(
MakeAndInitialize<VSISCallback>(&callback, parameter)
);
// Регистрируем callback
DX::ThrowIfFailed(
vsisNative->RegisterForUpdatesNeeded(callback.Get())
);
Шаг 3: Рисование
Для начала, получим все «грязные» регионы. После этого рисуем каждый из них:
void MainPage::UpdatesNeeded()
{
// Получаем количество перерисовываемых регионов
DWORD rectCount;
DX::ThrowIfFailed(
vsisNative->GetUpdateRectCount(&rectCount)
);
// Получаем сами регионы
std::unique_ptr<RECT[]> updateRects(new RECT[rectCount]);
DX::ThrowIfFailed(
vsisNative->GetUpdateRects(updateRects.get(), rectCount)
);
// Рисуем их
for (ULONG i = 0; i < rectCount; ++i)
{
RenderRegion(updateRects[i]);
}
}
И наконец мы подошли к долгожданному финалу нашей саги – рисование. Но сперва одна маленькая ремарка.
Пусть красный прямоугольник – наш текущий регион. Для данного региона вызов IVirtualSurfaceImageSourceNative::BeginDraw() даст нам нужный Surface, и уже на нём мы должны отрисовать всю область в красном прямоугольнике. По окончанию рисования вызываем IVirtualSurfaceImageSourceNative::EndDraw().
Что это означает? Что Surface будет отображать только текущий регион. То есть начало координат у этого Surface будет находиться в левом верхнем углу нашего региона, и нам не надо думать о лишних переносах. Выходить за пределы этого региона мы не можем.
На словах звучит немного запутанно, на практике все становится предельно ясным, так что приступим:
void MainPage::RenderRegion(const RECT& updateRect)
{
// Surface, куда мы будем рисовать
ComPtr<IDXGISurface> dxgiSurface;
// Смещение Surface'а
POINT surfaceOffset = { 0 };
HRESULT hr = vsisNative->BeginDraw(updateRect, &dxgiSurface, &surfaceOffset);
if (SUCCEEDED(hr))
{
// Превращаем наш Surface в Bitmap, на который и будем рисовать
ComPtr<ID2D1Bitmap1> targetBitmap;
DX::ThrowIfFailed(
d2dDeviceContext->CreateBitmapFromDxgiSurface(
dxgiSurface.Get(),
nullptr,
&targetBitmap
)
);
d2dDeviceContext->SetTarget(targetBitmap.Get());
// Делаем перенос на surfaceOffset
auto transform = D2D1::Matrix3x2F::Translation(
static_cast<float>(surfaceOffset.x),
static_cast<float>(surfaceOffset.y)
);
d2dDeviceContext->SetTransform(transform);
// Рисуем Bitmap
d2dDeviceContext->BeginDraw();
// ********************
// TODO: Рисуем здесь
// ********************
DX::ThrowIfFailed(
d2dDeviceContext->EndDraw()
);
// Подчищаем за собой
d2dDeviceContext->SetTarget(nullptr);
// Заканчиваем рисовать
DX::ThrowIfFailed(
vsisNative->EndDraw()
);
}
else if ((hr == DXGI_ERROR_DEVICE_REMOVED) || (hr == DXGI_ERROR_DEVICE_RESET))
{
// Обрбатываем сброс устройства
HandleDeviceLost();
// Пытаемся опять отрисовать updateRect
vsisNative->Invalidate(updateRect);
}
else
{
// Неизвестная ошибка
DX::ThrowIfFailed(hr);
}
}
Обратите внимание на получаемый surfaceOffset. Все наши художества должны быть сдвинуты на surfaceOffset. Сделано это в целях увеличения производительности, хотя нас такие тонкости волновать не должны. Самый простой способ смещения – матрица трансформаций.
В случае сброса устройства (по причине сбоя драйвера или еще чего-либо), пересоздаем устройство и помечаем текущий регион «грязным» с помощью IVirtualSurfaceImageSourceNative::Invalidate(). Таким образом VirtualSurfaceImageSource перерисует данный регион позднее.
1 : 0 в пользу Кэпа
Код данного примера на GitHub.
Итак, проделав по-настоящему трудный путь, я наконец запускаю приложение на своей люмии и… очень даже радуюсь! Увы, первое впечатление всегда обманчиво, и данный случай – не исключение. Я был очень огорчен, наблюдая невыносимые лаги при свайпах. Да, своей цели мы добились, но какой ценой? Выкладывать в Windows Store такую поделку никак нельзя, там и без него хватает всякого трэша.
Причина этих лагов, как всегда, банальна – блокировка UI потока. И если в случае C# приложений почти всегда спасает связка async+await, то в нашем случае с асинхронностью возникнут проблемы.
Наблюдательные читатели сразу же заметили «Часть 1» в заголовке данного поста. А всё потому, что я не охватил многих вещей. Например, Trim, из-за которого данное приложение не пройдет сертификацию в Windows Store. И самое главное – отрисовка в отдельном потоке. Таким образом мы убьем сразу двух зайцев: однопоточный трэш в коде примера и избавление от ужасных тормозов при прокрутке.
На сегодня всё. Желаю увлекательного кодинга и побольше счастливых пользователей!
Автор: dordzhiev