Капітан Америка vs VirtualSurfaceImageSource, Частина 1


Введення
Велику частину часу, розробка під 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 ГБ — поки що недосяжні, в моєму випадку, розкіш. Дуже шкода, доведеться шукати обхідні шляхи.

І що мені тепер робити?
Скажете ви. Варіантів взагалі-то небагато:
  1. Стиснути картинку до прийнятних розмірів
  2. Малювати тільки видиму на екрані частина
Перший варіант відразу відпадає, жертвувати якістю ми не будемо. Так що переходимо відразу до другого. Тут нас знову чекає роздоріжжі:
  1. Написати все самому
  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 обчислює їх за нас. Грубо кажучи, послідовність наших дій така:

  1. Отримуємо IVirtualSurfaceImageSourceNative
  2. Підписуємося на малювання з допомогою RegisterForUpdatesNeeded
  3. При виклику callback малюємо потрібний регіон


Disclaimer!І так, трохи не забув попередити — ніякого C# тут не буде! Так-так, в таких випадках доводиться виходити зі своєї зони комфорту. Але не поспішайте закривати вкладку, ключова частина статті застосовна і для Win2D. Обгортка над VirtualSurfaceImageSource вже занесена в roadmap, так що чекати залишилося зовсім трішки. Або можете зробити pull request зі своєю реалізацією. Я якраз планую цим зайнятися найближчим часом, так що чекайте оновлень!

Виглядає все дуже просто, залишилося тільки написати код. Малювати будемо з допомогою 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 що 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 що depend on hardware.
void MainPage::CreateDeviceResources()
{
    // This flag adds support для surfaces з a different color channel ordering than the API default.
    // It is recommended usage, and is required для compatibility з 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 для Modern style apps
        &d3dDevice,                 // returns the Direct3D device created
        &featureLevel,              // returns feature level of device created
        &d3dContext                 // returns the device immediate context
        )
        );
    // Obtain the транспортний DXGI device of the Direct3D11.1 device.
    DX::ThrowIfFailed(
        d3dDevice.As(&dxgiDevice)
        );
    // Obtain the Direct2D device для 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. Не 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 не 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 з 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 і = 0; і < 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. І найголовніше — відображення в окремому потоці. Таким чином ми вб'ємо відразу двох зайців: однопотоковий треш в коді прикладу і позбавлення від жахливих гальм при прокручуванні.

На сьогодні все. Бажаю захоплюючого кодинга і побільше щасливих користувачів!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.