
Wprowadzenie i wymagania projektu
Kilka miesięcy temu otrzymałem fascynujące zlecenie od jednego z moich klientów. Z określonych powodów, których nie mogę ujawnić, potrzebowali oni niestandardowego wewnętrznego rozwiązania do udostępniania ekranu z jednej ze swoich stacji roboczych. Kluczowym wymaganiem było, aby transmisja miała wyjątkowo niskie opóźnienia, zapewniając płynne i responsywne doświadczenie użytkownika.
Projekt ten stanowił kilka interesujących wyzwań technicznych, łącząc przechwytywanie ekranu w czasie rzeczywistym, wydajne kodowanie wideo i transmisję sieciową – wszystko przy zachowaniu minimalnego opóźnienia. Klient potrzebował rozwiązania, które przewyższałoby komercyjne produkty dla ich konkretnego przypadku użycia, przy czym wydajność była absolutnym priorytetem.
Mając wcześniejsze doświadczenie z programowaniem multimediów, byłem podekscytowany możliwością podjęcia tego wyzwania i stworzenia rozwiązania, które idealnie odpowiadałoby ich wymaganiom. W tym artykule przeprowadzę Cię przez proces rozwoju, koncentrując się szczególnie na aspektach transmisji wideo i optymalizacjach niezbędnych do osiągnięcia wydajności bliskiej czasu rzeczywistego.
Planowanie architektury systemu
Przed zagłębieniem się w kod, musiałem zaplanować architekturę systemu. Kluczowe pytania, na które musiałem odpowiedzieć, obejmowały:

- Jak wydajnie przechwytywać ekran i kursor
- Jak przesyłać przechwycone dane do innego komputera:
- Bezpośrednie połączenie nie jest możliwe z powodu zamkniętych portów
- Wysyłanie pełnych zrzutów ekranu byłoby niewspółmiernie nieefektywne
- Które protokoły sieciowe byłyby najbardziej odpowiednie
- Jak wyświetlać ekran na komputerze odbiorczym
- Jak przesyłać i wykonywać ruchy myszy i wprowadzanie danych z klawiatury
Po dokładnym rozważeniu ustaliłem, że będę potrzebował trzech oddzielnych aplikacji:
- Aplikacja kliencka na PC1 (C#?) – Komputer sterujący
- Serwer przekaźnikowy (C++) – Hostowany na serwerze do zarządzania połączeniami
- Aplikacja agenta na PC2 (C++) – Komputer, którym sterujemy

Serwer przekaźnikowy miał odgrywać kluczową rolę, zarządzając połączeniami i ułatwiając dwukierunkowe przesyłanie danych między komputerami. Rozwiązało to problem zamkniętych portów poprzez wykorzystanie publicznie dostępnego serwera jako pośrednika.
Ta architektura pozwala Klientowi i Agentowi na ustanowienie połączeń wychodzących do Serwera przekaźnikowego, który następnie przekazuje dane przechwytywania ekranu od Agenta do Klienta oraz polecenia sterujące od Klienta do Agenta.
Implementacja przechwytywania ekranu z DirectX
W przypadku komponentu przechwytywania ekranu miałem dwie główne opcje: używanie WinAPI (#include <windows.h>) lub wykorzystanie interfejsu DirectX `IDXGIOutputDuplication` (#include <dxgi.h>).
Wybrałem podejście DirectX, ponieważ zapewniłoby niższe opóźnienia i lepszą wydajność, co było kluczowe dla tego projektu.
Interfejs `IDXGIOutputDuplication` umożliwia bezpośredni dostęp do powierzchni pulpitu z pamięci GPU, zapewniając znacznie lepszą wydajność niż tradycyjne metody zrzutów ekranu.
Oto główna funkcja, którą zaimplementowałem do przechwytywania klatek:
Frame ScreenCapture::capture(UINT t_timeout)
{
spdlog::trace("{} Capturing frame with timeout: {}ms", SCR_CAP, t_timeout);
// Check if components are initialized
if (m_deskDupl == nullptr)
{
spdlog::error("{} Desktop duplication not initialized, attempting full reinitialization", SCR_CAP);
if (!reinitializeAll())
{
spdlog::error("{} Full reinitialization failed, cannot capture frame", SCR_CAP);
return Frame(0, 0);
}
}
if (m_d3dContext == nullptr || m_gpuTexture == nullptr)
{
spdlog::error("{} D3D11 context or texture not initialized, attempting full reinitialization", SCR_CAP);
if (!reinitializeAll())
{
spdlog::error("{} Full reinitialization failed, cannot capture frame", SCR_CAP);
return Frame(0, 0);
}
}
// Initialize the buffer with the correct dimensions
auto buffer = Frame(m_width, m_height);
DXGI_OUTDUPL_FRAME_INFO frameInfo;
Microsoft::WRL::ComPtr<IDXGIResource> desktopResource;
Microsoft::WRL::ComPtr<ID3D11Texture2D> acquiredTexture;
// Acquire the next frame
HRESULT hr = m_deskDupl->AcquireNextFrame(t_timeout, &frameInfo, &desktopResource);
// Handle different error conditions
if (hr == DXGI_ERROR_ACCESS_LOST)
{
spdlog::warn("{} Desktop duplication access lost, attempting to reinitialize", SCR_CAP);
if (!reinitialize())
{
spdlog::warn("{} Simple reinitialization failed, attempting full reinitialization", SCR_CAP);
if (!reinitializeAll())
{
spdlog::error("{} Full reinitialization failed after access lost", SCR_CAP);
return Frame(0, 0);
}
}
// Try again after reinitialization
hr = m_deskDupl->AcquireNextFrame(t_timeout, &frameInfo, &desktopResource);
}
else if (hr == DXGI_ERROR_INVALID_CALL || hr == E_INVALIDARG)
{
spdlog::warn("{} Invalid call to AcquireNextFrame, attempting full reinitialization", SCR_CAP);
if (!reinitializeAll())
{
spdlog::error("{} Full reinitialization failed", SCR_CAP);
return Frame(0, 0);
}
// Try again after reinitialization
hr = m_deskDupl->AcquireNextFrame(t_timeout, &frameInfo, &desktopResource);
}
else if (hr == DXGI_ERROR_WAIT_TIMEOUT)
{
// Timeout is not an error, just no new frame available
spdlog::trace("{} Timeout waiting for next frame", SCR_CAP);
return Frame(0, 0);
}
// If still failed after reinitialization attempts
if (FAILED(hr))
{
spdlog::trace("{} Failed to acquire next frame, error: {:#x}", SCR_CAP, hr);
return Frame(0, 0);
}
// Copy the acquired frame to our staging texture
hr = desktopResource.As(&acquiredTexture);
if (FAILED(hr))
{
spdlog::error("{} Failed to get texture from resource, error: {:#x}", SCR_CAP, hr);
m_deskDupl->ReleaseFrame();
return Frame(0, 0);
}
m_d3dContext->CopyResource(m_gpuTexture.Get(), acquiredTexture.Get());
m_deskDupl->ReleaseFrame();
// Map the staging texture to access the pixels
D3D11_MAPPED_SUBRESOURCE mapped;
hr = m_d3dContext->Map(m_gpuTexture.Get(), 0, D3D11_MAP_READ, 0, &mapped);
if (FAILED(hr))
{
spdlog::error("{} Failed to map texture, error: {:#x}", SCR_CAP, hr);
return Frame(0, 0);
}
// Convert BGRA to RGB
convertBGRAtoRGB(
static_cast<const uint8_t*>(mapped.pData),
buffer.data.get(),
m_width * m_height);
m_d3dContext->Unmap(m_gpuTexture.Get(), 0);
spdlog::trace("{} Frame captured successfully", SCR_CAP);
return buffer;
}
Ta implementacja obejmuje kilka kluczowych funkcji:
1. Obsługa błędów i odzyskiwanie – Kod elegancko obsługuje błędy, takie jak utrata dostępu do pulpitu lub nieprawidłowe wywołania, próbując ponownej inicjalizacji, gdy to konieczne
2. Zarządzanie limitem czasu – Jeśli żadna nowa klatka nie jest dostępna w określonym limicie czasu, funkcja zwraca pustą klatkę
3. Konwersja formatu – Przechwycona klatka jest konwertowana z BGRA (domyślny format DirectX) na RGB dla łatwiejszego przetwarzania
4. Szczegółowe logowanie – Kompleksowe logowanie pomaga w debugowaniu i monitorowaniu wydajności
Szczególnie użytecznym aspektem tej implementacji jest jej odporność. Komponent przechwytywania ekranu może odzyskać sprawność po różnych warunkach awarii, w tym gdy zmieniają się ustawienia wyświetlania lub gdy system przechodzi w stan uśpienia i budzi się ponownie.
Dzięki temu kodowi z powodzeniem rozwiązałem pierwsze wyzwanie: efektywne przechwytywanie ekranu z najwyższą możliwą częstotliwością klatek. Następnym krokiem była optymalizacja transmisji tych danych – równie krytyczny aspekt osiągnięcia udostępniania ekranu z niskim opóźnieniem.
Wyzwania optymalizacji wideo
Gdy miałem już działające przechwytywanie ekranu, musiałem rozwiązać problem transmisji danych. Wysyłanie surowych zrzutów ekranu byłoby niezwykle wymagające pod względem przepustowości i skutkowałoby nieakceptowalnym opóźnieniem. Moją początkową myślą było stworzenie niestandardowego rozwiązania, które analizowałoby klatki i przesyłało tylko te części obrazu, które zmieniły się między klatkami.

Teoretycznie wydawało się to sprytnym podejściem. Spędziłem kilka godzin opracowując algorytm, który wykrywałby różnice między kolejnymi klatkami i przesyłał tylko zmienione regiony. Zoptymalizowałem go nawet za pomocą CUDA, aby wykorzystać akcelerację GPU.
Jednak po znacznym wysiłku zdałem sobie sprawę, że zmierzam w złym kierunku. Moje niestandardowe rozwiązanie mogło osiągnąć co najwyżej 5-10 FPS, daleko od płynnego doświadczenia 30+ FPS, do którego dążyłem. Algorytm był po prostu zbyt intensywny pod względem wykorzystania CPU, aby spełnić wymagania projektu dotyczące czasu rzeczywistego.
Była to cenna lekcja, by nie wymyślać koła na nowo. Nowoczesne kodeki wideo już rozwiązują dokładnie ten problem poprzez estymację ruchu, przewidywanie międzyklatkowe i inne zaawansowane techniki, które zostały udoskonalone przez dziesięciolecia badań i rozwoju.
Stało się jasne, że muszę użyć sprawdzonego kodeka wideo, który zapewniłby efektywną kompresję przy zachowaniu jakości wizualnej i zminimalizowaniu opóźnień. Po ocenie kilku opcji zdecydowałem się na HEVC (High Efficiency Video Coding, znany również jako H.265) ze względu na jego doskonałą wydajność kompresji. Początkowo rozważałem AV1 ze względu na jego doskonałą kompresję, ale napotkałem kilka wyzwań implementacyjnych, które sprawiły, że HEVC był bardziej praktycznym wyborem dla tego projektu.
Implementacja kodowania HEVC z FFmpeg
Aby zaimplementować kodowanie HEVC, dodałem FFmpeg do mojego projektu za pośrednictwem vcpkg i opracowałem klasę opakowującą do obsługi procesu kodowania wideo. Oto jak używałem kodeka:
bool X265Encoder::sendFrameToCodec(const Frame& t_frame)
{
std::lock_guard<std::mutex> lock(m_encoderMutex);
if (av_frame_make_writable(m_avFrame) < 0)
return false;
const uint8_t* srcSlice[1] = { t_frame.data.get() };
int srcStride[1] = { 3 * t_frame.width };
// Convert from RGB24 to YUV420P.
sws_scale(m_swsCtx, srcSlice, srcStride, 0, t_frame.height,
m_avFrame->data, m_avFrame->linesize);
m_avFrame->pts = m_ptsCounter++;
int ret = avcodec_send_frame(m_codecCtx, m_avFrame);
return ret >= 0;
}
bool X265Encoder::getVideoDataFromCodec(std::vector<uint8_t>& t_packetData)
{
std::lock_guard<std::mutex> lock(m_encoderMutex);
AVPacket* pkt = av_packet_alloc();
int ret = avcodec_receive_packet(m_codecCtx, pkt);
if (ret == 0)
{
t_packetData.assign(pkt->data, pkt->data + pkt->size);
av_packet_unref(pkt);
av_packet_free(&pkt);
return true;
}
else
{
av_packet_free(&pkt);
return false;
}
}
Skupiłem się również na odpowiedniej konfiguracji kodeka dla operacji o niskim opóźnieniu:
m_codecCtx->width = t_settings.width;
m_codecCtx->height = t_settings.height;
m_codecCtx->time_base = { 1, t_settings.fps };
m_codecCtx->framerate = { t_settings.fps, 1 };
m_codecCtx->gop_size = t_settings.fps;
m_codecCtx->max_b_frames = 0; // No B-frames for low latency.
m_codecCtx->pix_fmt = AV_PIX_FMT_YUV420P;
m_codecCtx->bit_rate = static_cast<int>(t_settings.bitrate * 1000000);
if (m_codecCtx->priv_data)
{
av_opt_set(m_codecCtx->priv_data, "preset", "ultrafast", 0);
av_opt_set(m_codecCtx->priv_data, "tune", "zerolatency", 0);
av_opt_set(m_codecCtx->priv_data, "x265-params", "rc-lookahead=0", 0);
}
Kluczowe ustawienia dla niskiego opóźnienia to:
- Ustawienie max_b_frames na 0, aby wyeliminować klatki dwukierunkowe, które wprowadzają opóźnienie
- Użycie presetu „ultrafast” w celu zminimalizowania czasu kodowania
- Zastosowanie opcji dostrajania „zerolatency”
- Wyłączenie wyprzedzania za pomocą
rc-lookahead=0
, aby zapobiec buforowaniu klatek przez koder
Dzięki tym ustawieniom koder priorytetowo traktował szybkość kodowania ponad wydajność kompresji, co było dokładnie tym, czego potrzebowałem do udostępniania ekranu w czasie rzeczywistym.
Rozwiązywanie problemów z naruszeniem dostępu FFmpeg
Właśnie gdy myślałem, że wszystko działa, napotkałem krytyczny problem. Po ponownej instalacji moich zależności vcpkg, mój kod zaczął się zawieszać w bibliotece FFmpeg z następującym błędem:
avcodec_send_frame() in function
encode_simple_internal()
->
libx265_encode_frame()
->
ff_encode_encode_cb()
->
void av_freep()
->
void av_free(void *ptr)
{
#if HAVE_ALIGNED_MALLOC
_aligned_free(ptr); <-- HERE
#else
free(ptr);
#endif
}
Exception has occurred: W32/0xC0000005
Unhandled exception at 0x00007FFE5A43C55C (ucrtbased.dll) in AgentApp.exe: 0xC0000005: Access violation reading location 0xFFFFFFFFFFFFFFFF.
Początkowo myślałem, że to błąd w moim kodzie. Spędziłem kilka frustrujących dni na debuggowaniu, sprawdzając wycieki pamięci, nieprawidłową inicjalizację lub problemy z wątkami. Jednak po szeroko zakrojonym debugowaniu odkryłem, że problem wcale nie był w moim kodzie.
Problem wynikał z samej biblioteki FFmpeg – konkretnie z tego, jak została skompilowana przez vcpkg na moim systemie. Awaria występowała w kodzie obsługi wyrównania pamięci, gdzie _aligned_free()
było wywoływane z nieprawidłowym wskaźnikiem. Było to prawdopodobnie spowodowane problemem zgodności między skompilowaną biblioteką a konkretnym środowiskiem wykonawczym.
Po zbadaniu problemu nie mogłem znaleźć nikogo innego zgłaszającego ten sam problem, co sprawiło, że rozwiązywanie problemów było jeszcze trudniejsze. W końcu zdecydowałem się spróbować innego podejścia: nadal używałbym nagłówków FFmpeg z vcpkg do kompilacji, ale zastąpiłbym binaria wstępnie skompilowanymi wersjami z gyan.dev.
To pozornie niekonwencjonalne rozwiązanie okazało się idealne dla mojej sytuacji. Nie tylko rozwiązało problem, ale również przygotowało mnie do następnej optymalizacji, którą miałem zaimplementować – akceleracji sprzętowej. Wstępnie skompilowane binaria z gyan.dev zawierały wsparcie dla kodowania z akceleracją sprzętową, co okazało się kluczowe dla wydajności projektu.
Implementacja akceleracji sprzętowej
W tym momencie z powodzeniem zaimplementowałem przechwytywanie ekranu i kodowanie wideo z FFmpeg. Zauważyłem jednak znaczący problem – mój program zużywał 30-40% CPU! Było to znacznie więcej niż obserwowałem w komercyjnych aplikacjach, takich jak AnyDesk czy TeamViewer.
Po pewnych badaniach odkryłem problem: FFmpeg domyślnie używał kodowania programowego, które jest intensywne dla CPU. Rozwiązaniem było wykorzystanie kodowania z akceleracją sprzętową dostępnego na nowoczesnych GPU. Przeniosłoby to pracę kodowania z CPU na specjalne enkodery sprzętowe na karcie graficznej.
Dowiedziałem się, że FFmpeg musi być specjalnie skompilowany ze wsparciem dla sprzętowych API akceleracji, takich jak NVENC od NVIDIA, AMF od AMD czy VPL/QSV od Intela. Na szczęście wstępnie skompilowane binaria z gyan.dev, których już używałem, zawierały wsparcie dla tych enkoderów sprzętowych.
Dla implementacji NVIDIA skonfigurowałem koder w następujący sposób:
m_codecCtx->max_b_frames = 0; // No B-frames for low latency.
m_codecCtx->pix_fmt = AV_PIX_FMT_NV12; // NVENC prefers NV12 over YUV420P
m_codecCtx->bit_rate = static_cast<int>(t_settings.bitrate * 1000000); // Convert Mbps to bps.
spdlog::trace("{} Configured codec context with effective settings: {}x{}, {} fps, {} bps",
NVENC, m_codecCtx->width, m_codecCtx->height, m_codecCtx->framerate.num, m_codecCtx->bit_rate);
// Set NVENC specific options for low latency
if (m_codecCtx->priv_data)
{
spdlog::debug("{} Setting NVENC-specific low latency options", NVENC);
// Use p1 preset (fastest) for lowest latency
av_opt_set(m_codecCtx->priv_data, "preset", "p1", 0);
// Use ultra low latency tuning
av_opt_set(m_codecCtx->priv_data, "tune", "ull", 0);
// Set zero latency operation (no reordering delay)
av_opt_set_int(m_codecCtx->priv_data, "zerolatency", 1, 0);
// Disable lookahead
av_opt_set_int(m_codecCtx->priv_data, "rc-lookahead", 0, 0);
// Use CBR mode for consistent bitrate
av_opt_set_int(m_codecCtx->priv_data, "cbr", 1, 0);
}
else
{
spdlog::warn("{} Codec private data not available, skipping NVENC-specific options", NVENC);
}
Kluczowe różnice w porównaniu z konfiguracją kodera programowego obejmowały:
- Używanie formatu pikseli
AV_PIX_FMT_NV12
, preferowanego przez NVENC - Używanie presetów specyficznych dla NVENC:
- Preset „p1″ dla maksymalnej szybkości (w porównaniu do „ultrafast” w programowym x265)
- Opcja dostrajania „ull” (ultra-low-latency)
- Włączenie „cbr” (stała przepływność) dla spójnej wydajności
Zaimplementowałem podobne konfiguracje dla koderów AMD AMF i Intel VPL/QSV, tworząc trzy klasy koderów, które mogły być wybierane w zależności od dostępnego sprzętu w systemie.
Warto zauważyć, że różne enkodery sprzętowe mają własne konwencje nazewnictwa dla presetów. Podczas gdy programowy x265 używa nazw takich jak „ultrafast” i „medium”, NVENC używa „p1″ do „p7″ (gdzie p1 jest najszybszy), a inne enkodery sprzętowe mają własną terminologię.
Optymalizacja wydajności dla prawie zerowego zużycia CPU
Po zaimplementowaniu akceleracji sprzętowej wyniki były dramatyczne. Zużycie CPU spadło z 30-40% do prawie zera. Nowoczesne GPU mają dedykowane jednostki kodujące, które mogą obsłużyć kodowanie wideo z minimalnym wpływem na ogólną wydajność systemu lub możliwości graficzne.
Ten zysk efektywności był dokładnie tym, czego potrzebowałem dla rozwiązania do udostępniania ekranu o niskim opóźnieniu, które nie wpłynęłoby na zdolność użytkownika do wykonywania innych zadań podczas aktywnego udostępniania ekranu.
W końcowej implementacji dodałem kod wykrywania sprzętu, który automatycznie wybierałby najlepszy dostępny koder na podstawie sprzętu systemu. Jeśli kodowanie sprzętowe nie byłoby dostępne, powróciłby do kodowania programowego.
Podsumowanie
Implementacja kodowania z akceleracją sprzętową była kluczowa dla sukcesu tego projektu. Drastycznie zmniejszyła zużycie CPU z 30-40% do prawie zera, wykorzystując dedykowane jednostki kodujące na nowoczesnych GPU. Rozwiązanie uwzględnia różnice między koderami NVIDIA, AMD i Intel, z odpowiednimi optymalizacjami dla każdego.

Krytycznym aspektem była właściwa konfiguracja formatu pikseli – używanie NV12 dla koderów sprzętowych zamiast standardowego YUV420P używanego w kodowaniu programowym. Każdy producent GPU oferuje własne parametry optymalizacji, które wymagały dostrojenia, aby osiągnąć najniższe opóźnienie przy zachowaniu jakości obrazu.
Końcowy produkt osiągnął cel – udostępnianie ekranu z responsywnością bliską czasu rzeczywistego, wysoką jakością wizualną i minimalnym zużyciem zasobów systemowych, spełniając specyficzne wymagania klienta.