Хабр, привет! Я - Леонид Забурунов, инженер-программист компании "БФГ" - стартапа по разработке системы виртуализации - в отделе разработки систем виртуализации графических ресурсов.

В данном цикле статей мы исследуем самую объёмную часть протокола SPICE - доставку изображения удалённого рабочего стола. Напомню, что ранее мы бегло, но прицельно изучали графические архитектуры протокола SPICE и самой Windows. А сегодня мы разберёмся c устройством пайплайна видеотрансляции в реальном времени. Аспекты захвата экрана и сжатия видеопотока, которые попадут в статью, относятся не только к протоколам удалённого доступа в чистом виде. По схожим принципам устроены:

  • Системы облачного гейминга (напр. VK Play Cloud или NVIDIA GeForce NOW, не реклама).

  • Трансляции на Twitch/YouTube средствами какого-нибудь OBS Studio

  • Программы для записи экрана (напр. Bandicam)

  • Программы для видеозвонков (с небольшими подвижками, если речь о видео с веб-камеры)

  • Внешние карты захвата (напр. Avermedia)

  • Отсмотр камер видеонаблюдения

  • И прочая-прочая...

Есть смежные области, где нет трансляции именно в реальном времени (например, видео с YouTube или онлайн-кинотеатра). Там применяются крайне схожие принципы, просто вместо кадров прямо сейчас приходят заранее подготовленные пакеты. Отсутствие ограничения в виде real-time сильно развязывает руки инженерам - и в ход идут все возможности для оптимизации качества. Но эта область уже в данный цикл не попадает.

У нас роскоши в виде запаса времени нет. Запасайтесь миллисекундами и килобитами - и мы начинаем!

Навигатор по статье
Навигатор по циклу

Сверим часы

В этом разделе я объясню, что будет происходить дальше: ниже по тексту и в следующей части.
Если Вы не понимаете, о чём здесь речь, то перед прочтением данной статьи рекомендую обратиться к прошлым частям. Ссылки - в навигаторе выше.

Прежде чем приступить к основной теме, разберу один резонный вопрос: а зачем нам стриминг? Почему я вдруг задался целью сделать стриминговый агент, а не что-то ещё? Обсудим, чтобы стало понятно, зачем понадобилось знание методов захвата экрана и кодирования видео.

Мы разобрались, что идеи протокола SPICE не попали в светлое будущее и что с графической частью всё стало устроено гораздо проще: SPICE превратился в условный VNC (я сейчас только про трансляцию экрана, конечно). Поэтому стоит задать обратный вопрос: а зачем нам держаться за QXL? VNC и RDP ведь уже есть, мы не скажем в этом вопросе ничего нового, если будем придерживаться QXL-адаптера. Переход от фото-кодеков к видео-кодекам (подробнее см. в разделе первой части) очень тяжело осуществить при постоянно меняющемся разрешении, так как энкодеры в основном заточены на постоянную прямоугольную область.

Я не хочу сказать, что QXL-адаптер и вообще SPICE некуда развивать. Я хочу сказать, что QXL-адаптер в современных условиях приносит с собой фундаментальные ограничения. По этой причине я хочу представить альтернативное решение, работающее в режиме полноэкранной трансляции (стримим картинку). Там будут свои преимущества перед QXL - и, конечно, свои ограничения, которые тоже разберём.

При работе на персональном устройстве (localhost) вместо QXL проще просто пробросить свою интегрированную или дискретную GPU в виртуалку, воспользовавшись каким-нибудь VirGL (так и рекомендуют поступать для виртуалок со SPICE и современными дистрибутивами). А что мы будем делать с виртуализацией серверов в корпоративных нуждах? Там по умолчанию мы ожидаем два здоровых сокета под CPU - без интегрированной графики. GPU на каждую виртуальную машину не напасёшься, да и корпоративные модели стоят очень больно. Не домашнюю же в prod-сервер ставить.

Про виртуализацию GPU

В энтерпрайзе, конечно, решения уже придуманы. В основном используют аппаратную виртуализацию видеокарт, которую поддерживает производитель: NVIDIA vGPU для RTX/Tesla/Quadro, AMD MxGPU для Instinct/Radeon Pro V, Intel SR-IOV для Arc Pro (+ ранее GVT-g). Это актуально, когда нужна честная профессиональная нагрузка и доступ к полному десктопу. Дальше есть хитрости по GPU Passthrough с виртуализацией отдельных приложений. И выделенной фермой энкодеров. И много чем ещё. Обстоятельный рассказ обо всех этих прелестях жизни заслуживает отдельного цикла статей.

В конце концов, и потребительскую GPU в сервер никто не мешает поставить для энкодинга. Я такие эксперименты проводил, работает бодро. Просто из коробки тот же SPICE ничего с этой GPU не сделает, нужно дописывать код. А SPICE-сервер простым проектом я назвать не возьмусь. Поэтому...

В этих обстоятельствах моя цель - создать open-source стриминговый агент, который будет отвечать следующим требованиям:

  • Агент должен запускаться на любой виртуальной машине с Windows. В том числе там, где нет видеоадаптера вообще. Это главное преимущество QXL, отказываться от которого не хочется: работает он в наше время не лучшим образом, зато везде и всюду.

  • Агент должен дать возможность работать в полноэкранной трансляции с предсказуемой отзывчивостью. Пусть на первом этапе FPS будет оставлять желать лучшего, но он будет стабильным. У QXL есть с этим проблемы из-за рваного битрейта и отсутствия полноценного адаптивного качества.

  • Агент должен быть максимально прост в установке. Не сложнее обычных spice guest tools, чтобы у пользователя была возможность быстро попробовать.

Также архитектура агента при этом должна допускать будущую доработку для сценариев виртуализации GPU (использование аппаратных энкодеров). Это - следующий шаг, который сейчас в план не входит.

Приняв всё это, пойдём по порядку: сначала разберём доступные опции для захвата экрана, затем выберем видеокодеки.

Собираем кадры

Аналогия из детства: мультфильм в блокноте :) (Gemini)
Аналогия из детства: мультфильм в блокноте :) (Gemini)

Как и любое видео, realtime-трансляция - это последовательность картинок. Вспомните UI при запуске трансляции экрана в Skype/Zoom/Телемосте/choose-your-fighter: там предлагают либо захватывать весь экран целиком, либо выбрать одно из открытых окон. По большому счёту, вы с помощью удобного интерфейса сообщаете системе границы прямоугольника, по которому нужно захватывать экран.

Захват экрана в Windows

За что люблю Microsoft, так это за обратную совместимость. Помните, в прошлой части я говорил про GDI?

Короткая напоминалка: это устаревшее API эпохи рисования на CPU, разрабатываемое во времена Windows NT 3.X и Windows 2000.

Так вот, функции GDI эпохи мезозоя отлично работают до сих пор, включая Windows 11! Да, потеря производительности из-за ограниченной поддержки аппаратного ускорения; да, проблема с поддержкой UI scaling (в особенности, дробного) - и тем не менее, программа скомпилируется и заработает.

А за что не люблю?

За что не люблю Microsoft, так это за зоопарк технологий, который они породили. Применительно к нашей теме, зоопарк в части создания приложений с пользовательским интерфейсом. Вот, например, ОФИЦИАЛЬНАЯ сравнительная таблица средств разработки UI от Microsoft. Ламповый Win32, спроектированный ещё до рождения автора, старая-добрая дубина Windows Forms, а также для своего времени революционный WPF соседствуют с четырьмя ньюфагами технологиями посвежее:

  • React Native (привет фронтэндерам);

  • MAUI (ранее известный как Xamarin);

  • UWP (представленный к релизу Windows 10 и уже устаревший в пользу следующего пункта);

  • WinUI (уже в третьем релизе и пока остающийся рекомендуемым выбором от Microsoft).

На кой ляд так много? Такова цена за разнообразие устройств в орбите Microsoft: непосредственно Windows для ПК и ноутбуков, его мобильные версии (а помните про Windows Mobile и Windows Phone?), Windows IoT, планшеты Surface, XBOX, AR-очки HoloLens. И, конечно, за некоторую непоследовательность самих Microsoft в развитии своих продуктов.

Какой бы UI-фреймворк Вы не использовали, он даёт вам свои возможности для получения скриншота (за исключением WinForms, который является С#-обёрткой над Win32 и просто вызывает GDI через WinAPI). Но это работает для получения содержимого буфера конкретного окна, а не всего дисплея.

На любом устройстве на Windows до сих пор можно реализовать трансляцию экрана с помощью GDI. Будь то физическое устройство или виртуальная машина в SPICE. Конечно, есть и более современные способы захвата экрана:

Классификация доступных способов захвата экрана в Windows (Gemini)
Классификация доступных способов захвата экрана в Windows (Gemini)

Пойдём по порядку.

Захват экрана через GDI

Захват экрана в WinAPI осуществляется с помощью старой-доброй функции BitBlt из GDI, реализующей копирование региона от одного устройства к другому. С ней мы уже познакомились при сравнении GDI и X11. Развёрнутый пример использования BitBlt для сохранения скриншота в файл есть в официальной документации, здесь же я приведу максимально компактный код:

// Получаем Device Context всего рабочего стола.
// NULL означает "весь экран", а не конкретное окно.
HDC hdcScreen = GetDC(NULL);

// Создаём memory DC — виртуальное "устройство" в системной памяти,
// совместимое по формату пикселей с hdcScreen.
// Само по себе ещё ни на что не указывает — нужна поверхность.
HDC hdcMem = CreateCompatibleDC(hdcScreen);

// Создаём bitmap в системной памяти — это и есть буфер куда попадёт захват.
// Формат (глубина цвета, порядок каналов) наследуется от hdcScreen.
HBITMAP hBmp = CreateCompatibleBitmap(hdcScreen, w, h);

// Связываем bitmap с memory DC.
// До этого момента hdcMem указывал на дефолтный однопиксельный bitmap —
// только после SelectObject он готов принимать реальные данные.
SelectObject(hdcMem, hBmp);

// Собственно захват: копируем прямоугольник (x, y, w, h) из hdcScreen в hdcMem.
// SRCCOPY — простое копирование без растровых операций.
// После этого вызова hBmp содержит снимок экрана в системной памяти.
BitBlt(hdcMem, 0, 0, w, h, hdcScreen, x, y, SRCCOPY);

BITMAPINFOHEADER bih{ sizeof(bih), w, -h, 1, 32, BI_RGB }; // -h = top-down
BITMAPINFO bmi{ bih };
GetDIBits(hdcMem, hBmp, 0, h, pPixels, &bmi, DIB_RGB_COLORS);
// pPixels теперь содержит BGRA-кадр, готовый к передаче в кодек

// Освобождение ресурсов — обязательно, утечки DC в GDI глобальны для системы
SelectObject(hdcMem, hBmpOld); // вернуть старый bitmap перед удалением DC
DeleteObject(hBmp);
DeleteDC(hdcMem);
ReleaseDC(NULL, hdcScreen); // ReleaseDC, не DeleteDC — для DC полученных через GetDC

(комментарии частично сгенерированы)

Microsoft любят называть свои функции так, чтобы код читался не сложнее романа - и, как мне кажется, здесь это соблюдается. Всё достаточно прямолинейно: настроил контекст устройства, вызвал операцию копирования, получил результат в свой кадровый буфер - готово!

GDI, даже будучи безнадёжно устаревшим, показывает себя неплохо. При разрешении монитора 1920х1080 можно рассчитывать на 30-60 FPS (конкретные результаты зависят от singlethread-производительности CPU).


В SPICE также предусмотрен отдельный канал для работы с курсором (см. более подробное объяснение в этой статье), поэтому я не могу обойти этот вопрос стороной. При работе с GDI это выглядит как-то так:

// Получаем метаданные курсора
CURSORINFO ci = {};
ci.cbSize = sizeof(ci);
GetCursorInfo(&ci);

// ci.flags определяет статус курсора: CURSOR_SHOWING / CURSOR_SUPPRESSED / 0 (скрыт)
// ci.ptScreenPos даёт экранные координаты (X; Y)
// ci.hCursor даёт дескриптор курсора

// Получаем геометрию курсора
ICONINFO ii = {};
GetIconInfo(ci.hCursor, &ii);

// ii.xHotspot, ii.yHotspot дают точку битмапы, от которой исходит клик мыши
// ii.hbmMask - AND-маска (1bpp, всегда есть) + XOR-маска у монохромных курсоров
// ii.hbmColor - цветной слой (32bpp) или NULL у монохромных курсоров

// ... здесь вся работа с курсором: наложить прямо на битмапу, отправить в SPICE-канал и т. д.

// GetIconInfo копирует битмапу с курсором, поэтому после работы данные нужно очистить
DeleteObject(ii.hbmMask);
if (ii.hbmColor) DeleteObject(ii.hbmColor);

Захват экрана через DirectX 9

Этот способ родился на рассвете графических ускорителей, но задолго до появления Desktop Duplication API. Он опирается на использование в D3D9 offscreen surface, куда можно было выгружать данные буфера устройства.

Сегодня это API мертво, все лучшие идеи перекочевали в DXGI. По этой причине примера кода в статье не будет, но если кому-то интересно погружение в прошлое, то примерно вот так это выглядит.

Захват экрана через DXGI

При разборе графической подсистемы Windows я останавливался на устройстве Desktop Window Manager - современного композитора, встроенного начиная с Windows Vista. Важной частью новой архитектуры является DirectX Graphics Infrastructure - общий модуль с базовыми функциями для DX10+, реализация которого не меняется с инкрементом версии API: опрос доступных устройств, работа со swap chain и другое.

DXGI сегодня используют все тулкиты захвата экрана, будь то протоколы облачного гейминга или плагины OBS для стриминга. Если в вашей ОС имеется видеоадаптер с функциональностью аппаратного сжатия видео (стандарт для GPU последних 10 лет), то можно работать с видеопотоком прямо в VRAM, да ещё и в асинхронном режиме (планированием обычно занимается видеодрайвер). Чтение из VRAM в системную память - операция очень тяжёлая и дорогостоящая, поэтому минимизация проходящего объёма данных стала необходимостью. И производители GPU предложили решение в виде встроенного медиапроцессора.

Посмотрим на DXGI Desktop Duplication API. По сути работа с этим API - это программирование на DirectX, а значит про небольшой объём кода и понятную с хода логику можно забыть. Вот официальная документация Microsoft с отдельными примерами кода и демо-проект на Github. А вот компактный - насколько это возможно - пример кода:

// Шаг 1: Получаем доступ к графическому адаптеру и монитору через DXGI.
// IDXGIAdapter — абстракция GPU, IDXGIOutput — абстракция монитора.
// Один GPU может иметь несколько Output'ов (несколько мониторов).
IDXGIAdapter* adapter = ...;
IDXGIOutput* output = ...;

IDXGIOutput1* dxgiOutput1;
output->QueryInterface(__uuidof(IDXGIOutput1), (void**)&dxgiOutput1);

// Шаг 2: Создаём D3D11-устройство — оно нужно не для рендеринга,
// а как контекст для работы с GPU-текстурами которые мы будем получать.
ID3D11Device* d3dDevice = ...;

// Шаг 3: Создаём контекст дублирования монитора.
// С этого момента DWM знает что кто-то читает его выходной буфер.
// Важно: работает только из процесса с доступом к рабочему столу
// (не из сервиса).
IDXGIOutputDuplication* duplication;
dxgiOutput1->DuplicateOutput(d3dDevice, &duplication);

// Шаг 4: Основной цикл захвата.
while (capturing) {
    DXGI_OUTDUPL_FRAME_INFO frameInfo;
    IDXGIResource* desktopResource;

    // Блокируется до появления нового кадра от DWM — никакого polling'а.
    // timeout в миллисекундах; INFINITE допустим если не нужен контроль частоты.
    // frameInfo содержит метаданные: время кадра, состояние курсора,
    // список dirty rects и move rects — регионов которые изменились
    // или просто сдвинулись (например, при прокрутке).
    // Это позволяет передавать только дельту, а не весь кадр целиком.
    duplication->AcquireNextFrame(timeout, &frameInfo, &desktopResource);

    // ... обработка курсора ...

    // Получаем кадр как D3D11-текстуру.
    // Данные при этом остаются на GPU — никакого копирования ещё не было.
    ID3D11Texture2D* desktopImage;
    desktopResource->QueryInterface(__uuidof(ID3D11Texture2D), (void**)&desktopImage);

    // Здесь два пути:
    //
    // А) Обработка на GPU — передаём desktopImage напрямую в энкодер
    //    (NVENC, AMF, QSV) или в шейдер. Копий в системную память нет.
    //    Это основа захвата в OBS, ShadowPlay и т.д.
    //
    // Б) Обработка на CPU — копируем текстуру в staging buffer
    //    (D3D11_USAGE_STAGING), затем Map() даёт указатель на сырые пиксели.
    //    Медленнее из-за roundtrip GPU→RAM, но иногда необходимо.

    // Обязательно освобождаем кадр до следующего AcquireNextFrame.
    // Не вызвать ReleaseFrame — утечка и зависание следующего Acquire.
    duplication->ReleaseFrame();
    desktopResource->Release();
}

// Шаг 5: Освобождение ресурсов.
// DWM перестаёт отслеживать получателя после Release дублирования.
duplication->Release();
dxgiOutput1->Release();

(комментарии частично сгенерированы)


Внутри того же цикла while обрабатывается и курсор:

while (capturing) {
    DXGI_OUTDUPL_FRAME_INFO frameInfo;
    IDXGIResource* desktopResource;
    duplication->AcquireNextFrame(timeout, &frameInfo, &desktopResource);

    // frameInfo.LastMouseUpdateTime == 0 означает, что курсор остался неизменным.
    // Позиция и видимость - сразу в frameInfo, больше ничего вызывать не нужно.
    if (frameInfo.LastMouseUpdateTime.QuadPart != 0) {
        bool visible = frameInfo.PointerPosition.Visible;
        int cursorX = frameInfo.PointerPosition.Position.x;
        int cursorY = frameInfo.PointerPosition.Position.y;

        // Форма курсора (bitmap) обновляется отдельно и реже чем позиция.
        // PointerShapeBufferSize > 0 только когда форма реально изменилась.
        if (frameInfo.PointerShapeBufferSize > 0) {
            std::vector<BYTE> shapeBuffer(frameInfo.PointerShapeBufferSize);
            DXGI_OUTDUPL_POINTER_SHAPE_INFO shapeInfo;
            UINT bufferSizeRequired;

            duplication->GetFramePointerShape(
                frameInfo.PointerShapeBufferSize,
                shapeBuffer.data(),
                &bufferSizeRequired,
                &shapeInfo
            );

            // shapeInfo.Type: 
            //  - MONOCHROME (AND- и XOR-маски, аналогично примеру GDI выше)
            //  - COLOR (RGBA 32 bit)
            //  - MASKED_COLOR (то же самое, но A=255 означает XOR с экраном)
            // shapeInfo.HotSpot — hotspot внутри битмапы (аналогично ICONINFO в GDI)
            // shapeInfo.Width, shapeInfo.Height — размеры битмапы в пикселях
            // shapeInfo.Pitch — шаг строки в байтах
            //  (в случае RGBA Pitch = 4 * Width, для маски Pitch = Width)
        }
    }

    // ... обработка основного кадра ...

    duplication->ReleaseFrame();
    desktopResource->Release();
}

Если вы не работали в геймдеве и не знакомы с API трёхмерной графики, то этот код запросто может напугать. Из-за того, что это API ориентировано на GPU, помимо объёмного кода нужно ориентироваться ещё и в нюансах аппаратной архитектуры самой видеокарты и её общения с CPU - иначе итоговая производительность нас не устроит.

На базе Desktop Duplication API можно писать полноценные драйверы захвата экрана (я это делал). Они будут выглядеть так, как и положено драйверам и системным утилитам в мире Microsoft: без пол-литра не разберёшься. Но награда за страдания - доступ к максимально возможной для Windows оптимизации пайплайна.

Захват экрана через WinRT

Ну и теперь Windows.Graphics.Capture. Это часть современного системного SDK WinRT, который работает на C++ и нацелен на разрешение проблем с безопасностью при доступе к низкоуровневым возможностям ОС. WGC - более высокоуровневый API: он использует DXGI, чтобы предоставить пользователю event-driven парадигму взаимодействия вместо опроса в цикле, который мы видели выше. Это крайне полезно для случаев, когда низкоуровневое управление не нужно. С помощью этого API удобно захватывать содержимое отдельных окон, а также оно учитывает окна с защищённым контентом (DRM).

winrt::com_ptr<ID3D11Device> d3dDevice = ...;  // Получаем через D3D11CreateDevice()
// Прямой дескриптор устройства DirectX 11 не сработает, 
//  нужно сделать интероп для совместимости
winrt::com_ptr<IDXGIDevice> dxgiDevice;
d3dDevice->QueryInterface(dxgiDevice.put());
winrt::IDirect3DDevice winrtDevice;
CreateDirect3D11DeviceFromDXGIDevice(
    dxgiDevice.get(),
    reinterpret_cast<::IInspectable**>(winrt::put_abi(winrtDevice))
);

// Захватывать можно окно (HWND) или монитор (HMONITOR)
winrt::GraphicsCaptureItem item = ...;

// Готовим кольцевой буфер для новых кадров
auto pool = winrt::Direct3D11CaptureFramePool::Create(
    winrtDevice,
    winrt::DirectXPixelFormat::B8G8R8A8UIntNormalized,
    /*frameCount = */ 2,
    item.Size());

// Асинхронный обработчик события получения нового кадра
pool.FrameArrived([&](auto& pool, auto&) {
    auto frame = pool.TryGetNextFrame();

    
    // Обработка такая же, как для DXGI
    ProcessFrame(frame.Surface());

    // Фрейм освобождается автоматически (RAII)
});

auto session = pool.CreateCaptureSession(item);
session.StartCapture();  // FrameArrived начинает вызываться асинхронно

// ...

session.Close();
pool.Close();

(частично сгенерировано)

Интересное API, но для драйвера захвата экрана я бы его использовать не стал (кроме случаев, когда уже есть код на WinRT, куда отлично впишется новый функционал захвата). Основной выигрыш здесь в изоляции от низкоуровнего API, что для системных компонентов не всегда хорошо.

WGC был бы отличным выбором в случае тех же программ для ВКС, особенно в случае с транслированием отдельного приложения.

Захват экрана через хуки WinAPI

Помните такую программу как FRAPS?

Интерфейс FRAPS
Интерфейс FRAPS

Мы ведь сегодня говорим только про Windows, куда же мы без хуков? :) FRAPS как раз работал через хуки.

FRAPS - инструмент замера FPS, записи экрана и получения скриншотов для приложений, использующих DirectX или OpenGL. Он был крайне популярен во времена до Windows 7 включительно. И запускался от имени администратора.

Изложу суть очень упрощённо. Любое графическое API устроено так: инициализация, исполнение цепочки команд по отрисовке в текущую кадровую текстуру, передача сигнала о готовности кадра. На примере всех популярных:

  • DirectX 9 использует вызов IDirect3DSwapChain9::Present (добавил для истории)

  • DirectX 10/11 использует вызов IDXGISwapChain::Present

  • OpenGL использует вызов glSwapBuffers (префикс меняется от активного API для управления окнами)

  • Vulkan использует вызов vkQueuePresent с дополнительной синхронизацией через семафоры

  • (для полноты картины) Metal использует вызов MTLCommandBuffer::present

  • DirectX 12 использует вызов IDXGISwapChain::Present с семафорами аналогично Vulkan

Собственно, функцию present как раз и можно хукнуть (дотеры, молчите, не выдавайте себя). Что FRAPS и делал. И это ещё один, пусть и замороченный метод записи экрана в Windows.

Порядок в списке API не случайный:

  • Первые три устроены так, что ты просто вызываешь команду, а там драйвер дальше как-то сам. Такая архитектура - идеальный кандидат на то, чтобы перехватить функцию, украсть содержимое буфера и передать вызов дальше в драйвер, как и планировало приложение.

  • Современные API перешли на явную модель - вообще почти во всех аспектах. Включая вопросы управления вводом-выводом. И синхронизацией через примитивы (semaphore/fence). И доступ к текстурам.

Этот архитектурный сдвиг, а также появление Desktop Duplication API (и потом WinRT) сделали FRAPS слишком сложным: под каждое графическое API нужен свой хук, а зачем городить, когда есть легальные методы кражи текстур? В итоге FRAPS уже давно не поддерживается. R. I. P.

Что касается самих хуков, то пример кода я в статью вставлять не буду - не наша тема. Кому интересно - вот пример на Github с хуками для DX11 и DX12.

Подытог

Для наиболее широкой поддержки основная рабочая лошадка - это GDI. DXGI опирается на использование GPU, поэтому отложим в сторону. WinRT - то же самое, так как использует под капотом DXGI, и к тому же слишком прикладной для нашей задачи. Хуки - это больше про привязку к конкретному приложению, да и в целом выглядит как стрельба из пушки по воробьям.

На самом деле, DXGI может работать со встроенным в Windows программным эмулятором GPU, но в данном случае я не хочу на это опираться. Если в рамках создания пилота убрать аппаратное ускорение за скобки, а потом использовать DirectX, то в голове читателя создастся путаница.

Теперь - ко второй части. Сделаем так, чтобы захваченные кадры уместились в реалистичные ограничения по битрейту.

Сжимаем кадры

То, что видео является последовательностью картинок - это мы все хорошо понимаем. Однако, если бы картинки были наивно склеены одна за другой в видео, такая наивная покадровая развёртка не поместилась бы ни на хранилище пользователя, ни в возможности сетевого оборудования. Современные фильмы в Blu-ray формате весят на торрентах по 40-60 гигабайт - при том, что там используется крайне эффективное сжатие. А без видеокодеков могли бы достигать сотен гигабайт - либо нам пришлось бы сильно терять в качестве (сравните фотографии со смартфона 5-7 летней давности и с профессионального зеркального объектива).

Способов сжатия существует много: разные кодеки, разные ресурсы для кодирования, разные виды API. Ну вот примерно так:

Пайплайн сжатия видео на верхнем уровне с различными видами используемых компонент (Gemini)
Пайплайн сжатия видео на верхнем уровне с различными видами используемых компонент (Gemini)

Когда мы говорим о транслировании в реальном времени, важнейшая инженерная задача - побороть задержки, которые исходят от каждого используемого компонента:

  • Энкодер требует времени на обработку каждого кадра (оно измеряется в десятках миллисекунд по умолчанию, для профилей под Remote Desktop - 2-8 мс в зависимости от производительности CPU и выбранных настроек при разрешении 1080p);

  • Энкодер создаёт сдвиг по кадрам из-за того, что для обработки нового кадра нужно сверяться с несколькими предыдущими. Снизить этот сдвиг до нуля здесь никак не выйдет, зачастую будет оставаться асинхронность в 1-2 кадра;

  • Если работать через фреймворк, он может иметь свои очереди, вносящие задержку. Самая очевидная причина: паразитные копирования данных;

  • Сам драйвер транслирования экрана не может работать с абсолютно нулевой задержкой, требуется время на переключение контекста и передачу данных с этапа захвата в этап сжатия;

  • Полученный пакет со сжатым фреймом нужно доставить по сети до клиентского устройства. Это, вполне возможно, будет самым большим слагаемым;

  • На клиентском устройстве декодер, вполне возможно, будет иметь свой сдвиг в 1-2 кадра, чтобы полностью достроить картинку по диффам и предсказателям движения (с постоянными корректировками);

  • На клиентском устройстве тоже может возникать задержка из-за паразитных копирований данных;

  • Наконец, даже с уже готовым декодированным кадром мы имеем задержку на обновление содержимого экрана (вертикальная синхронизация, системные таймеры и т. д.)

Что в итоге? На вход вы подали кадр N, а энкодер в ответ вам пакет, соответствующий кадру N-3 (на усреднённых настройках). При этом N-2 реконструируется на клиенте, а N-1 ползёт по сети. Пользователь почувствует эту задержку, в этом можно даже не сомневаться. И оступиться можно на каждом шагу. Поэтому качественный удалённый доступ - это результат применённого таланта системного программирования, магии вне Хогвартса и порции скупых слёз.

Упрощённая картина задержек на всех этапах доставки рабочего стола до пользователя (Gemini)
Упрощённая картина задержек на всех этапах доставки рабочего стола до пользователя (Gemini)

Теперь пройдёмся по частям схемы с типами энкодеров.

Аппаратные ресурсы

Реализаций для каждого стандарта сжатия существует уж точно больше одной. Как правило, есть каноническая реализация в виде программного энкодера, то есть работающего на CPU. Эта реализация создаётся рабочей группой, являющейся и автором самого стандарта. Затем, производители аппаратных устройств могут вводить свои реализации в виде аппаратного энкодера, то есть работающего с помощью отдельной микросхемы на GPU или вообще выделенного промышленного PCI-E энкодера. Свои реализации могут представлять и производители проприетарного ПО, например, существует Adobe Media Encoder. Или вот, openh264, альтернативная реализация стандарта H264 от компании Cisco. В общем, много всего.

Поскольку я нацелен на максимально широкую поддержку ОС Windows и конфигураций виртуальных машин, аппаратные энкодеры сейчас выпадают. Будем придерживаться канонических реализаций.

Видеокодеки

Замечали, что YouTube и другие хостинги жмут загружаемое видео подолгу? Для платформодержателя важно сэкономить своё внутреннее дисковое пространство и дать пользователю качественный поток, поэтому ты, дорогой автор, подождёшь...Мы НЕ можем одновременно и получить хорошую картинку, и сэкономить битрейт, и сделать это всё быстро (уж тем более в real-time). Увеличить задержку мы не можем, битрейт тоже не резиновый, поэтому экономить будем (в основном) на качестве.

Дилемма при работе с видеоэнкодерами (Gemini)
Дилемма при работе с видеоэнкодерами (Gemini)

Возьмём для примера разрешение 1080p, которое сегодня одно из наиболее популярных. Мы хотим трансляцию, допустим, в 60 FPS, потому что 60 Гц - это стандарт комфортной работы, ниже которого мы опускаться не хотим. Да, потребление видеоконтента и некоторые виды работ в приложениях можно проводить и при тридцати кадрах, но это уже компромисс. Разрешение 1920х1080 - это что-то около 2 миллионов пикселей. Каждый пиксель монитора занимает 4 байта (RGBA 8-бит, HDR и 10-битные дисплеи не трогаем). Итого: 1920 * 1080 * 4 * 60 = ~480 МБ/с ≈ 4 Гбит/с

Это - объём сырых данных, которые:

  • Будут проходить по системным шинам внутри компьютера - виртуализованным, если мы говорим про виртуальные машины - при каждом копировании памяти. Zero-copy в таких случаях - не демонстрация технических навыков, а жизненная необходимость;

  • Будут поступать на вход энкодера - и энкодер должен уметь быстро эти данные конвертировать в пакеты для отправки по сети;

  • Зеркальным образом отразятся на стороне приёма - декодер будет выплёвывать столько же, сколько получил энкодер, а устройство пользователя должно будет прогонять по своим системным шинам столько же, сколько и на стороне отправки;

JPEG (MJPEG)

MJPEG игнорирует межкадровую разницу. Каждый кадр упаковывается в JPEG независимо от соседних (Gemini)
MJPEG игнорирует межкадровую разницу. Каждый кадр упаковывается в JPEG независимо от соседних (Gemini)

Как устроен MJPEG? Вы закрываете глаза на то, что перед Вами видео, и просто каждый кадр рабочего стола сжимаете как обычную JPEG-картинку. Если сравнивать с полноценными видеокодеками, использующими межкадровое сжатие (стандарты H264/265, VP9, AV1), то MJPEG битрейта жрёт много (раз в 5-10 больше перечисленных оппонентов - для статичной картинки, до 20-50 раз - для проигрывания видео), а сжатие проходит быстро (примерно во столько же).

Как реализовать? Классика жанра - использование libjpeg или libjpeg-turbo для сжатия на процессоре. Можно прибегать и к экзотике, например, использовать nvJPEG для о-о-очень быстрого преобразования на видеоадаптерах NVIDIA.

JPEG-XL

Из нового - появился стандарт JPEG-XL с канонической реализацией. Формат новый, поддержка пока хромает. В сравнении с оригинальным JPEG показывает более эффективное сжатие, но требует сильно больше ресурсов и, что для нас важнее всего, времени на преобразование. По этой причине, его мы пока откладываем в сторону.

Для сжатия видео в production-системе MJPEG не подходит, это скорее для тестирования в локальной сети - или вообще для виртуальной машины на localhost. Но для нас это - вполне себе валидный сценарий применения, и отличный fallback в случае, если другие кодеки откажутся работать. Да и сам SPICE его использует как fallback для видео. Поэтому в стриминговом агенте вариант со сжатием через MJPEG будет.

H264, H265, VP9

Современные кодеки имеют множество настроек, позволяющее подстроить энкодер под нужный сценарий использования. На изображении: варианты работы libx264 (Gemini)
Современные кодеки имеют множество настроек, позволяющее подстроить энкодер под нужный сценарий использования. На изображении: варианты работы libx264 (Gemini)

Задача любого видеокодека, если очень упрощать, - избавиться от избыточности и создать иллюзию первозданной картинки изо всех сил. Здесь может быть речь и о простом дублировании информации - в таком случае блоки пикселей можно просто проиндексировать, - так и про визуальную плотность видеопотока, которую можно урезать - в частности, так, что зритель никакого подвоха не увидит. В отличие от архиваторов и их классических задач сжатия без потерь, здесь мы допускаем потери, определяемые метриками визуального качества.

Это чем-то похоже на кэши в компьютере, где важная роль отведена принципам локальности:

  • Пространственная локальность (intra-frame compression) - соседние пиксели склонны иметь схожие значения, поэтому применяются квантование для понижения разрядности и различные манипуляции над цветовым пространством;

  • Временная локальность (inter-frame compression) - два соседних кадра склонны дублировать друг друга во многих участках, поэтому мы можем с очередным кадром передавать только изменения относительно предыдущего состояния. Примерно как git diff.

Всё это требует существенных ресурсов - вычислительных и временных.

Ну, а дальше - кто во что горазд. В современных стандартах применяется очень много различных техник сжатия, коротко обо всём не расскажешь, для интересующихся рекомендую хорошую статью с обзором принципов работы H264 и потрясающую статью про сравнение онлайн-сервисов для просмотра видео.

В стриминговый агент полноценные кодеки, само собой, будут включены. Какие конкретно?

Задействуемые API

Прямая работа с библиотеками

... - это лобовой способ, который порой хочется сравнить с программированием на ассемблере. Хороший кодек предоставляет огромное количество параметров: если под рукой имеется установленная libx264 (одна из наиболее популярных реализаций для сжатия видео) с CLI-обёрткой x264, то перечисление доступных опций через x264 --fullhelp займёт в высоту несколько экранов.

libjpeg я в агенте буду использовать напрямую, потому что с MJPEG всё гораздо проще, а вот для кодеков посложнее...

Медиафреймворки (FFmpeg/GStreamer)

... - это станки на все случаи жизни. Фреймворки объединяют под единым API огромное количество самых разных инструментов работы с фото/видео/аудио, в том числе энкодеров.

Что FFmpeg, что GStreamer имеют огромный спектр возможностей и являются базовыми компонентами для многих проигрывателей видео/аудио. Например, VLC использует FFmpeg. Оба доступны для использования как в CLI, так и через прямое подключение библиотек.

Про FFmpeg я даже делал статью. Также есть много других полезных материалов по теме, например, вот этот.

Код со сжатием кадров рабочего стола будет выглядеть как-то так (для FFmpeg):

// Выбираем энкодер
const AVCodec *codec = ...; // avcodec_find_encoder_by_name() или avcodec_find_encoder()

AVCodecContext *ctx = avcodec_alloc_context3(codec);

// ...
// Здесь настраиваем опции энкодера (скорость работы, выходное качество и т. д.)
// и параметры видеопотока (разрешение, FPS, лимиты по битрейту и т. д.)
// ...

avcodec_open2(ctx, codec, NULL);

// Как правило, все кодеки работают с YUV вместо RGB
// Для преобразования нужен специальный компонент
struct SwsContext *sws = sws_getContext(
    1920, 1080, AV_PIX_FMT_RGBA, // Параметры входного потока
    1920, 1080, AV_PIX_FMT_YUV420P, // Параметры выходного потока
    SWS_BILINEAR, NULL, NULL, NULL // Параметры преобразователя
);

// Готовим память для фрейма
// (сюда SwsContext будет писать сконвертированные фреймы)
AVFrame *frame = av_frame_alloc();
frame->format = AV_PIX_FMT_YUV420P;
frame->width = ctx->width;
frame->height = ctx->height;
av_frame_get_buffer(frame, 0);

// Готовим пакет для сжатых данных
// (это уйдёт в сеть и попадёт на декодер клиента)
AVPacket *pkt = av_packet_alloc();

while (encoding) {
    // Получаем данные захвата
    uint8_t *rgb_data = get_next_frame();

    // Конвертируем цветовое пространство
    const uint8_t *src[1] = { rgb_data };
    int src_stride[1] = { 1920 * 4 }; // длина строки RGBA-изображения
    sws_scale(sws, src, src_stride, 0, 1080, frame->data, frame->linesize);

    // Отправляем сконвертированный фрейм на сжатие (внутри - асинхронная очередь)
    avcodec_send_frame(ctx, frame);

    // Получаем пакеты со сжатыми данными
    if (avcodec_receive_packet(ctx, pkt) == 0) {
        
        // ... обработка пакета (отправка в сокет и т. д.) ...

        av_packet_unref(pkt);
    }
}

avcodec_free_context(&ctx);
av_frame_free(&frame);
av_packet_free(&pkt);
sws_freeContext(sws);

Фреймворки прячут управление множеством мелочей, позволяя нам с помощью одного и того же кода работать с множеством кодеков (с минимумом изменений, на уровне одного-двух параметров функций). По этой причине в стриминговый агент я обязательно включу FFmpeg с переключением различных опций.

Энкодеры в API 3D-графики (DirectX/Vulkan)

... - это новый способ работы с видео в присутствии GPU. В DirectX такое появилось только начиная с DirectX 12 в Windows 11, в Vulkan это появилось несколько лет назад.

Я не мог не упомянуть про это API для аппаратных энкодеров, но для наших целей - мимо. Мало того, что требуется поддержка GPU, так ещё и поддерживаются только самые последние версии драйверов и Windows. По этой причине кода здесь не будет, интересующихся отсылаю к официальным примерам для Vulkan и к внушительной документации по энкодерам H264/H265 для DirectX 12.

Подытог

В таких задачах я придерживаюсь работы через FFmpeg в случае программных энкодеров (и прямой работы с API в случае аппаратных). Особенности отдельных библиотек в решаемой задаче нас особенно не интересуют, а от привязки к аппаратному ускорению мы, напомню, избавляемся.

SPICE сегодня поддерживает следующие видеокодеки:

typedef enum SpiceVideoCodecType {
    SPICE_VIDEO_CODEC_TYPE_MJPEG = 1,
    SPICE_VIDEO_CODEC_TYPE_VP8,
    SPICE_VIDEO_CODEC_TYPE_H264,
    SPICE_VIDEO_CODEC_TYPE_VP9,
    SPICE_VIDEO_CODEC_TYPE_H265,
} SpiceVideoCodecType;

По этой причине в стриминговый агент я перенесу FFmpeg + libx264 (для кодека H264), libx265 (для кодека H265) и libvpx (для кодеков VP8/VP9, оба реализованы в одной библиотеке). А за MJPEG будет отвечать чистый libjpeg-turbo.

Заключение

Фух, закончили! Я постарался в одну статью уместить несколько больших тем - в том объёме, в котором они касаются сформулированной в начале задачи. Надеюсь, что ясность изложения не пострадала.

ОС Windows за историю своего существования успела породить такое количество технологий, что проще скорее сказать, чего мы не видели. Потребность в видео у человечества тоже устойчивая уже на протяжении многих лет, поэтому решений огромное множество на все случаи жизни.

Пайплайн захвата экрана и сжатия видео требует тщательной подготовки: как в нюансах всех базовых технологий, так и при написании кода. В задаче Remote Desktop нужно минимизировать задержку всеми средствами. В случае с выбранным путём стриминговый агент будет экономить на качестве. В production-ready протоколах доставки рабочего стола применяется множество интересных решений: автоматическое управление битрейтом (для максимизации качества), дублирование кадров для "проталкивания" асинхронных задержек, передача отдельных областей экрана (совмещаем image-based подход со стримингом) и много чего ещё. В рамках создания пилотного решения всё это я опускаю, потому что в противном случае фокус очень быстро потеряется и придётся пересказывать десятилетия прогресса в области.

В следующей серии я уже покажу сам агент: соберём проект, интегрируем в SPICE. Посмотрим, что получилось и ради чего пришлось погрузиться во множество интересных тем и раскрыть их в трёх частях :)

До встречи в будущих статьях, счастливо!