【C++】從零開始,只使用FFmpeg,Win32 API,實現一個播放器(一)

最後的紳士發表於2021-05-04

前言

起初只是想做一個直接讀取視訊檔案然後播放字元動畫的程式。我的設想很簡單,只要有現成的庫,幫我把視訊檔案解析成一幀一幀的原始畫面資訊,那麼我只需要讀取裡面的每一個畫素的RGB數值,計算出亮度,然後根據亮度對映到某個字元,再把這些字元全部拼起來顯示出來,事情就完成了。於是我就開始研究怎麼用 FFmpeg 這個庫,網上還是能比較容易找到相關的教程,不久我就把目標實現了。

image

之後我就想,要不乾脆就做一個正經的播放器看看吧,結果,我就遇到了一堆的問題,寫這篇文章的目的,就是把這些問題的探索過程,和我自己的答案,分享出來。

因為不打算跨平臺,所以沒有使用任何構建系統,直接開啟Visual Studio 2019新建專案開擼就行。我不打算展現高超的軟體工程技巧,以及完美的錯誤處理,所以程式碼都是一把梭哈,怎麼直接簡單怎麼來,重點是說清楚這事兒到底怎麼幹、怎麼起步,剩下的事情就交給大家自由發揮了。

本來想一篇寫完,後面覺得實在是太長了,特別是後面 DirectX 11 的渲染部分太複雜了,DirectX 9 還算簡單,所以第一篇,先把dx9渲染說完,第二篇,再說dx11。

一個簡單的視窗

現在都2021年了,實際產品基本不會有人直接用 Win32 API 寫 GUI,我之所以還選擇這麼做,是因為想把底層的東西說明白,但是不想引入太多額外的東西,例如QT、SDL之類的GUI庫,況且我也沒想過真的要做成一個實用工具。實際上我一開始的版本就是用 SDL 2.0 做的,後面才慢慢脫離,自己寫渲染程式碼。

image

首先要說的是,在專案屬性 - 連結器 - 系統 - 子系統 選擇 視窗 (/SUBSYSTEM:WINDOWS),就可以讓程式啟動的時候,不出現控制檯視窗。當然,這其實也無關緊要,即使是使用 控制檯 (/SUBSYSTEM:CONSOLE),也不妨礙程式功能正常執行。

建立視窗的核心函式,是 CreateWindow(準確的說:是CreateWindowA或者CreateWindowW,這兩個才是 User32.dll 的匯出函式名字,但為了方便,之後我都會用引入 Windows 標頭檔案定義的巨集作為函式名稱,這個務必注意),但它足足有 11 個引數要填,十分勸退。

auto window = CreateWindow(className, L"Hello World 標題", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL);

className 是視窗類名,待會再細說,L"Hello World 標題" 就是將會出現在視窗標題欄的文字,WS_OVERLAPPEDWINDOW是一個巨集,代表視窗樣式,比如當你想要一個無邊框無標題欄的視窗時,就要用另外一些樣式。CW_USEDEFAULT, CW_USEDEFAULT, 800, 600分別代表視窗出現的位置座標和寬高,位置我們使用預設就行,大小可以自己指定,剩下的引數在目前不太重要,全部是NULL也完全沒有問題。

在呼叫 CreateWindow 之前,通常還要呼叫 RegisterClass,註冊一個視窗類,類名可以隨便取。

auto className = L"MyWindow";
WNDCLASSW wndClass = {};
wndClass.hInstance = hInstance;
wndClass.lpszClassName = className;
wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
	return DefWindowProc(hwnd, msg, wParam, lParam);
};

RegisterClass(&wndClass);

WNDCLASSW結構體也有很多需要設定的內容,但其實必不可少的就是兩個,lpszClassName 和 lpfnWndProc,hInstance 這裡也不是必須的。lpszClassName 就是是類名,而 lpfnWndProc 是一個函式指標,每當視窗接收到訊息時,就會呼叫這個函式。這裡我們可以使用 C++ 11 的 Lambda 表示式,賦值到 lpfnWndProc 的時候它會自動轉換為純函式指標,而且你無需擔心 stdcall cdecl 呼叫約定問題,前提是我們不能使用變數捕捉特性。

return DefWindowProc(hwnd, msg, wParam, lParam);的作用是把訊息交給Windows作預設處理,比如點選標題欄右上角的×會關閉視窗,以及最大化最小化等等預設行為,這些行為都可以由使用者自行接管,後面我們就會在這裡處理滑鼠鍵盤等訊息了。

預設剛剛建立的視窗是隱藏的,所以我們要呼叫 ShowWindow 顯示視窗,最後使用訊息迴圈讓視窗持續接收訊息。

ShowWindow(window, SW_SHOW);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}

最後別忘了在程式最開頭呼叫 SetProcessDPIAware(),防止Windows在顯示縮放大於100%時,自行拉伸窗體導致顯示模糊。

完整的程式碼看起來就是這樣:

#include <stdio.h>
#include <Windows.h>

int WINAPI WinMain (
	_In_ HINSTANCE hInstance,
	_In_opt_ HINSTANCE hPrevInstance,
	_In_ LPSTR lpCmdLine,
	_In_ int nShowCmd
) {
	SetProcessDPIAware();

	auto className = L"MyWindow";
	WNDCLASSW wndClass = {};
	wndClass.hInstance = NULL;
	wndClass.lpszClassName = className;
	wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
		return DefWindowProc(hwnd, msg, wParam, lParam);
	};

	RegisterClass(&wndClass);
	auto window = CreateWindow(className, L"Hello World 標題", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, NULL, NULL);

	ShowWindow(window, SW_SHOW);

	MSG msg;
	while (GetMessage(&msg, window, 0, 0) > 0) {
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}

	return 0;
}

效果:

image

引入FFmpeg

我們就不費心從原始碼編譯了,直接下載編譯好的檔案就行:https://github.com/BtbN/FFmpeg-Builds/releases,注意下載帶shared的版本,例如:ffmpeg-N-102192-gc7c138e411-win64-gpl-shared.zip,解壓後有三個資料夾,分別是 bin, include, lib,這分別對應了三個需要配置的東西。

接下來建立兩個環境變數,注意目錄改為你的實際解壓目錄:

  • FFMPEG_INCLUDE = D:\Download\ffmpeg-N-102192-gc7c138e411-win64-gpl-shared\include
  • FFMPEG_LIB = D:\Download\ffmpeg-N-102192-gc7c138e411-win64-gpl-shared\lib

注意每次修改環境變數,都需要重啟Visual Studio。然後配置 VC++目錄 中的包含目錄和庫目錄

image

然後就可以在程式碼中引入FFmpeg的標頭檔案,並且正常編譯了:

extern "C" {
#include <libavcodec/avcodec.h>
#pragma comment(lib, "avcodec.lib")

#include <libavformat/avformat.h>
#pragma comment(lib, "avformat.lib")

#include <libavutil/imgutils.h>
#pragma comment(lib, "avutil.lib")

}

最後還要在環境變數PATH加入路徑 D:\Download\ffmpeg-N-102192-gc7c138e411-win64-gpl-shared\bin,以便讓程式執行時正確載入FFmpeg的dll。

解碼第一幀畫面

接下來我們編寫一個函式,獲取到第一幀的畫素集合。

AVFrame* getFirstFrame(const char* filePath) {
	AVFormatContext* fmtCtx = nullptr;
	avformat_open_input(&fmtCtx, filePath, NULL, NULL);
	avformat_find_stream_info(fmtCtx, NULL);

	int videoStreamIndex;
	AVCodecContext* vcodecCtx = nullptr;
	for (int i = 0; i < fmtCtx->nb_streams; i++) {
		AVStream* stream = fmtCtx->streams[i];
		if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
			const AVCodec* codec = avcodec_find_decoder(stream->codecpar->codec_id);
			videoStreamIndex = i;
			vcodecCtx = avcodec_alloc_context3(codec);
			avcodec_parameters_to_context(vcodecCtx, fmtCtx->streams[i]->codecpar);
			avcodec_open2(vcodecCtx, codec, NULL);
		}
	}

	while (1) {
		AVPacket* packet = av_packet_alloc();
		int ret = av_read_frame(fmtCtx, packet);
		if (ret == 0 && packet->stream_index == videoStreamIndex) {
			ret = avcodec_send_packet(vcodecCtx, packet);
			if (ret == 0) {
				AVFrame* frame = av_frame_alloc();
				ret = avcodec_receive_frame(vcodecCtx, frame);
				if (ret == 0) {
					av_packet_unref(packet);
					avcodec_free_context(&vcodecCtx);
					avformat_close_input(&fmtCtx);
					return frame;
				}
				else if (ret == AVERROR(EAGAIN)) {
					av_frame_unref(frame);
					continue;
				}
			}
		}

		av_packet_unref(packet);
	}
}

流程簡單來說,就是:

  1. 獲取 AVFormatContext,這個代表這個視訊檔案的容器
  2. 獲取 AVStream,一個視訊檔案會有多個流,視訊流、音訊流等等其他資源,我們目前只關注視訊流,所以這裡有一個判斷 stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO
  3. 獲取 AVCodec,代表某個流對應的解碼器
  4. 獲取 AVCodecContext,代表解碼器的解碼上下文環境
  5. 進入解碼迴圈,呼叫用 av_read_frame 獲取 AVPacket,判斷是否是視訊流的資料包,是則呼叫 avcodec_send_packet 傳送給 AVCodecContext 進行解碼,有時一個資料包是不足以解碼出完整的一幀畫面的,此時就要獲取下一個資料包,再次呼叫 avcodec_send_packet 傳送到解碼器,嘗試是否解碼成功。
  6. 最後通過 avcodec_receive_frame 得到的 AVFrame 裡面就包含了原始畫面資訊

很多視訊畫面第一幀都是全黑的,不方便測試,所以可以稍微改改程式碼,多讀取後面的幾幀。

AVFrame* getFirstFrame(const char* filePath, int frameIndex) {
// ...
	n++;
	if (n == frameIndex) {
		av_packet_unref(packet);
		avcodec_free_context(&vcodecCtx);
		avformat_close_input(&fmtCtx);
		return frame;
	}
	else {
		av_frame_unref(frame);
	}
// ...
}

可以直接通過AVFrame讀取到畫面的width, height

AVFrame* firstframe = getFirstFrame(filePath.c_str(), 10);

int width = firstframe->width;
int height = firstframe->height;

我們們關注的原始畫面畫素資訊在 AVFrame::data 中,他的具體結構,取決於 AVFrame::format,這是視訊所使用的畫素格式,目前大多數視訊都是用的YUV420P(AVPixelFormat::AV_PIX_FMT_YUV420P),為了方便,我們就只考慮它的處理。

渲染第一幀畫面

與我們設想的不同,大多數視訊所採用的畫素格式並不是RGB,而是YUV,Y代表亮度,UV代表色度、濃度。最關鍵是的它有不同的取樣方式,最常見的YUV420P,每一個畫素,都單獨儲存1位元組的Y值,每4個畫素,共用1個U和1個V值,所以,一幅1920x1080的影像,僅佔用 1920 * 1080 * (1 + (1 + 1) / 4) = 3110400 位元組,是RGB編碼的一半。這裡利用了人眼對亮度敏感,但對顏色相對不敏感的特性,即使降低了色度頻寬,感官上也不會過於失真。

但Windows沒法直接渲染YUV的資料,因此需要轉換。這裡為了儘快看到畫面,我們先只使用Y值來顯示出黑白畫面,具體做法如下:

struct Color_RGB
{
	uint8_t r;
	uint8_t g;
	uint8_t b;
};

AVFrame* firstframe = getFirstFrame(filePath.c_str(), 30);

int width = firstframe->width;
int height = firstframe->height;

vector<Color_RGB> pixels(width * height);
for (int i = 0; i < pixels.size(); i++) {
	uint8_t r = firstframe->data[0][i];
	uint8_t g = r;
	uint8_t b = r;
	pixels[i] = { r, g, b };
}

YUV420P格式會把Y、U、V三個值分開儲存到三個陣列,AVFrame::data[0] 就是Y通道陣列,我們簡單的把亮度值同時放進RGB就可以實現黑白畫面了。接下來寫一個函式對處理出來的RGB陣列進行渲染,我們這裡先使用最傳統的GDI繪圖方式:

void StretchBits (HWND hwnd, const vector<Color_RGB>& bits, int width, int height) {
	auto hdc = GetDC(hwnd);
	for (int x = 0; x < width; x++) {
		for (int y = 0; y < height; y++) {
			auto& pixel = bits[x + y * width];
			SetPixel(hdc, x, y, RGB(pixel.r, pixel.g, pixel.b));
		}
	}
	ReleaseDC(hwnd, hdc);
}

ShowWindow 呼叫之後,呼叫上面寫的 StretchBits 函式,就會看到畫面逐漸出現在視窗中了:

//...
ShowWindow(window, SW_SHOW);

StretchBits(window, pixels, width, height);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}
// ...

image

一個顯而易見的問題,就是渲染效率太低了,顯示一幀就花了好幾秒,對於普通每秒24幀的視訊來說這完全不能接受,所以我們接下來嘗試逐漸優化 StretchBits 函式。

優化GDI渲染

SetPixel 函式很顯然效率太低了,一個更好的方案是使用 StretchDIBits 函式,但是他用起來沒有那麼簡單直接。

void StretchBits (HWND hwnd, const vector<Color_RGB>& bits, int width, int height) {
	auto hdc = GetDC(hwnd);
	BITMAPINFO bitinfo = {};
	auto& bmiHeader = bitinfo.bmiHeader;
	bmiHeader.biSize = sizeof(bitinfo.bmiHeader);
	bmiHeader.biWidth = width;
	bmiHeader.biHeight = -height;
	bmiHeader.biPlanes = 1;
	bmiHeader.biBitCount = 24;
	bmiHeader.biCompression = BI_RGB;

	StretchDIBits(hdc, 0, 0, width, height, 0, 0, width, height, &bits[0], &bitinfo, DIB_RGB_COLORS, SRCCOPY);
	ReleaseDC(hwnd, hdc);
}

注意 bmiHeader.biHeight = -height; 這裡必須要使用加一個負號,否則畫面會發生上下倒轉,在 BITMAPINFOHEADER structure 裡有詳細說明。這時我們渲染一幀畫面的時間就縮短到了幾毫秒了。

播放連續的畫面

首先我們要拆解 getFirstFrame 函式,把迴圈解碼的部分單獨抽出來,分解為兩個函式:InitDecoderRequestFrame

struct DecoderParam
{
	AVFormatContext* fmtCtx;
	AVCodecContext* vcodecCtx;
	int width;
	int height;
	int videoStreamIndex;
};

void InitDecoder(const char* filePath, DecoderParam& param) {
	AVFormatContext* fmtCtx = nullptr;
	avformat_open_input(&fmtCtx, filePath, NULL, NULL);
	avformat_find_stream_info(fmtCtx, NULL);

	AVCodecContext* vcodecCtx = nullptr;
	for (int i = 0; i < fmtCtx->nb_streams; i++) {
		const AVCodec* codec = avcodec_find_decoder(fmtCtx->streams[i]->codecpar->codec_id);
		if (codec->type == AVMEDIA_TYPE_VIDEO) {
			param.videoStreamIndex = i;
			vcodecCtx = avcodec_alloc_context3(codec);
			avcodec_parameters_to_context(vcodecCtx, fmtCtx->streams[i]->codecpar);
			avcodec_open2(vcodecCtx, codec, NULL);
		}
	}

	param.fmtCtx = fmtCtx;
	param.vcodecCtx = vcodecCtx;
	param.width = vcodecCtx->width;
	param.height = vcodecCtx->height;
}

AVFrame* RequestFrame(DecoderParam& param) {
	auto& fmtCtx = param.fmtCtx;
	auto& vcodecCtx = param.vcodecCtx;
	auto& videoStreamIndex = param.videoStreamIndex;

	while (1) {
		AVPacket* packet = av_packet_alloc();
		int ret = av_read_frame(fmtCtx, packet);
		if (ret == 0 && packet->stream_index == videoStreamIndex) {
			ret = avcodec_send_packet(vcodecCtx, packet);
			if (ret == 0) {
				AVFrame* frame = av_frame_alloc();
				ret = avcodec_receive_frame(vcodecCtx, frame);
				if (ret == 0) {
					av_packet_unref(packet);
					return frame;
				}
				else if (ret == AVERROR(EAGAIN)) {
					av_frame_unref(frame);
				}
			}
		}

		av_packet_unref(packet);
	}

	return nullptr;
}

然後在 main 函式中這樣寫:

// ...
DecoderParam decoderParam;
InitDecoder(filePath.c_str(), decoderParam);
auto& width = decoderParam.width;
auto& height = decoderParam.height;
auto& fmtCtx = decoderParam.fmtCtx;
auto& vcodecCtx = decoderParam.vcodecCtx;

auto window = CreateWindow(className, L"Hello World 標題", WS_OVERLAPPEDWINDOW, 0, 0, decoderParam.width, decoderParam.height, NULL, NULL, hInstance, NULL);

ShowWindow(window, SW_SHOW);

MSG msg;
while (GetMessage(&msg, window, 0, 0) > 0) {
	AVFrame* frame = RequestFrame(decoderParam);

	vector<Color_RGB> pixels(width * height);
	for (int i = 0; i < pixels.size(); i++) {
		uint8_t r = frame->data[0][i];
		uint8_t g = r;
		uint8_t b = r;
		pixels[i] = { r, g, b };
	}

	av_frame_free(&frame);

	StretchBits(window, pixels, width, height);

	TranslateMessage(&msg);
	DispatchMessage(&msg);
}
// ...

此時執行程式,發現畫面還是不動,只有當我們的滑鼠在視窗不斷移動時,畫面才會連續播放。這是因為我們使用了 GetMessage,當視窗沒有任何訊息時,該函式會一直阻塞,直到有新的訊息才會返回。當我們用滑鼠在視窗上不斷移動其實就相當於不斷向視窗傳送滑鼠事件訊息,才得以讓while迴圈不斷執行。

解決辦法就是用 PeekMessage 代替,該函式不管有沒有接收到訊息,都會返回。我們稍微改改訊息迴圈程式碼:

// ...
wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
	switch (msg)
	{
	case WM_DESTROY:
		PostQuitMessage(0);
		return 0;
	default:
		return DefWindowProc(hwnd, msg, wParam, lParam);
	}
};
// ...
while (1) {
	BOOL hasMsg = PeekMessage(&msg, NULL, 0, 0, PM_REMOVE);
	if (hasMsg) {
		if (msg.message == WM_QUIT) {
			break;
		}
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}
	else {
		AVFrame* frame = RequestFrame(decoderParam);

		vector<Color_RGB> pixels(width * height);
		for (int i = 0; i < pixels.size(); i++) {
			uint8_t r = frame->data[0][i];
			uint8_t g = r;
			uint8_t b = r;
			pixels[i] = { r, g, b };
		}

		av_frame_free(&frame);

		StretchBits(window, pixels, width, height);
	}
}

注意改用了 PeekMessage 後需要手動處理一下 WM_DESTROYWM_QUIT 訊息。此時即使滑鼠不移動畫面也能連續播放了。但在我筆記本 i5-1035G1 那孱弱效能下,畫面效果比PPT還慘,此時只要把VS的生成配置從 Debug 改為 Release,畫面直接就像按了快進鍵一樣,這程式碼優化開與不開有時候真是天差地別。

這裡插播一下 Visual Studio 的效能診斷工具,實在是太強大了。

image

可以清晰看到那一句程式碼,哪一個函式,佔用了多少CPU,利用它可以很方便的找到最需要優化的地方。可以看到vector的分配佔用了大部分的CPU時間,待會我們再搞搞它。

彩色畫面

FFmpeg 自帶有函式可以幫我們處理顏色編碼的轉換,為此我們需要引入新的標頭檔案:

// ...
#include <libswscale/swscale.h>
#pragma comment(lib, "swscale.lib")
// ...

然後編寫一個新函式用來轉換顏色編碼

vector<Color_RGB> GetRGBPixels(AVFrame* frame) {
	static SwsContext* swsctx = nullptr;
	swsctx = sws_getCachedContext(
		swsctx,
		frame->width, frame->height, (AVPixelFormat)frame->format,
		frame->width, frame->height, AVPixelFormat::AV_PIX_FMT_BGR24, NULL, NULL, NULL, NULL);

	vector<Color_RGB> buffer(frame->width * frame->height);
	uint8_t* data[] = { (uint8_t*)&buffer[0] };
	int linesize[] = { frame->width * 3 };
	sws_scale(swsctx, frame->data, frame->linesize, 0, frame->height, data, linesize);

	return buffer;
}

sws_scale 函式可以對畫面進行縮放,同時還能改變顏色編碼,這裡我們不需要進行縮放,所以 width 和 height 保持一致即可。

然後在解碼後呼叫:

// ...
AVFrame* frame = RequestFrame(decoderParam);

vector<Color_RGB> pixels = GetRGBPixels(frame);

av_frame_free(&frame);

StretchBits(window, pixels, width, height);
// ...

效果還不錯:

image

接下來稍微優化下程式碼,在 Debug 模式下,vector 分配記憶體似乎需要消耗不少效能,我們想辦法在訊息迴圈前就分配好。

vector<Color_RGB> GetRGBPixels(AVFrame* frame, vector<Color_RGB>& buffer) {
	static SwsContext* swsctx = nullptr;
	swsctx = sws_getCachedContext(
		swsctx,
		frame->width, frame->height, (AVPixelFormat)frame->format,
		frame->width, frame->height, AVPixelFormat::AV_PIX_FMT_BGR24, NULL, NULL, NULL, NULL);

	uint8_t* data[] = { (uint8_t*)&buffer[0] };
	int linesize[] = { frame->width * 3 };
	sws_scale(swsctx, frame->data, frame->linesize, 0, frame->height, data, linesize);

	return buffer;
}

// ...
InitDecoder(filePath.c_str(), decoderParam);
auto& width = decoderParam.width;
auto& height = decoderParam.height;
auto& fmtCtx = decoderParam.fmtCtx;
auto& vcodecCtx = decoderParam.vcodecCtx;

vector<Color_RGB> buffer(width * height);
// ...
while (1) {
// ...
vector<Color_RGB> pixels = GetRGBPixels(frame, buffer);
// ...
}

這下即使是Debug模式下也不會卡成ppt了。

正確的播放速度

目前我們的畫面播放速度,是取決於你的CPU運算速度,那要如何控制好每一幀的呈現時機呢?一個簡單的想法,是先獲取視訊的幀率,計算出每一幀應當間隔多長時間,然後在每一幀呈現過後,呼叫 Sleep 函式延遲,總之先試試:

AVFrame* frame = RequestFrame(decoderParam);

vector<Color_RGB> pixels = GetRGBPixels(frame, buffer);

av_frame_free(&frame);

StretchBits(window, pixels, width, height);

double framerate = (double)vcodecCtx->framerate.den / vcodecCtx->framerate.num;
Sleep(framerate * 1000);

AVCodecContext::framerate 可以獲取視訊的幀率,代表每秒需要呈現多少幀,他是 AVRational 型別,類似於分數,num 是分子,den 是分母。這裡我們把他倒過來,再乘以1000得出每幀需要等待的毫秒數。

但實際觀感發現速度是偏慢的,這是因為解碼和渲染本身就要消耗不少時間,再和Sleep等待的時間疊加,實際上每幀間隔的時間是拉長了的,下面我們嘗試解決這個問題:

// ...
#include <chrono>
#include <thread>
// ...

using namespace std::chrono;
// ...

int WINAPI WinMain (
	_In_ HINSTANCE hInstance,
	_In_opt_ HINSTANCE hPrevInstance,
	_In_ LPSTR lpCmdLine,
	_In_ int nShowCmd
) {
// ...

	auto currentTime = system_clock::now();

	MSG msg;
	while (1) {
		BOOL hasMsg = PeekMessage(&msg, NULL, 0, 0, PM_REMOVE);
		if (hasMsg) {
			// ...
		} else {
			// ...
			
			av_frame_free(&frame);

			double framerate = (double)vcodecCtx->framerate.den / vcodecCtx->framerate.num;
			std::this_thread::sleep_until(currentTime + milliseconds((int)(framerate * 1000)));
			currentTime = system_clock::now();

			StretchBits(window, pixels, width, height);
		}
	}

std::this_thread::sleep_until 能夠延遲到指定的時間點,利用這個特性,即使解碼和渲染佔用了時間,也不會影響整體延遲時間,除非你的解碼渲染一幀的時間已經超過了每幀間隔時間。

放心,這個笨拙的方式當然不會是我們的最終方案。

硬體解碼

使用這個程式在我的筆記本上還是能流暢播放 1080p24fps 視訊的,但是當播放 1080p60fps 視訊的時候明顯跟不上了,我們先來看看是哪裡佔用CPU最多:

image

顯然 RequestFrame 佔用了不少資源,這是解碼使用的函式,下面嘗試使用硬體解碼,看看能不能提高效率:

void InitDecoder(const char* filePath, DecoderParam& param) {
	// ...

	// 啟用硬體解碼器
	AVBufferRef* hw_device_ctx = nullptr;
	av_hwdevice_ctx_create(&hw_device_ctx, AVHWDeviceType::AV_HWDEVICE_TYPE_DXVA2, NULL, NULL, NULL);
	vcodecCtx->hw_device_ctx = hw_device_ctx;

	param.fmtCtx = fmtCtx;
	param.vcodecCtx = vcodecCtx;
	param.width = vcodecCtx->width;
	param.height = vcodecCtx->height;
}

vector<Color_RGB> GetRGBPixels(AVFrame* frame, vector<Color_RGB>& buffer) {
	AVFrame* swFrame = av_frame_alloc();
	av_hwframe_transfer_data(swFrame, frame, 0);
	frame = swFrame;

	static SwsContext* swsctx = nullptr;
	
	// ...
	
	sws_scale(swsctx, frame->data, frame->linesize, 0, frame->height, data, linesize);

	av_frame_free(&swFrame);

	return buffer;
}

先通過 av_hwdevice_ctx_create 建立一個硬體解碼裝置,再把裝置指標賦值到 AVCodecContext::hw_device_ctx 即可,AV_HWDEVICE_TYPE_DXVA2 是一個硬體解碼裝置的型別,和你執行的平臺相關,在Windows平臺,通常使用 AV_HWDEVICE_TYPE_DXVA2 或者 AV_HWDEVICE_TYPE_D3D11VA,相容性最好,因為後面要用 dx9 渲染,所以我們先用dxva2。

此時解碼出來的 AVFrame,是沒法直接訪問到原始畫面資訊的,因為解碼出來的資料都還在GPU視訊記憶體當中,需要通過 av_hwframe_transfer_data 複製出來(這就是播放器裡面的copy-back選項),而且出來的顏色編碼變成了 AV_PIX_FMT_NV12,並非之前常見的 AV_PIX_FMT_YUV420P,但這不需要擔心,sws_scale 能幫我們處理好。

執行程式後,在工作管理員確實看到了GPU有一定的佔用了:

image

但還是不夠流暢,我們再看看效能分析:

image

看來是 sws_scale 函式消耗了效能,但這是FFmpeg的函式,我們無法從他的內部進行優化,總之先暫時擱置吧,以後再解決它。

使用D3D9渲染畫面

GDI 渲染那都是古法了,現在我們整點近代的方法:Direct3D 9 渲染。

先引入必要的標頭檔案:

#include <d3d9.h>
#pragma comment(lib, "d3d9.lib")

還有一個微軟給我們的福利,ComPtr:

#include <wrl.h>
using Microsoft::WRL::ComPtr;

因為接下來我們會大量使用 COM(元件物件模型)技術,有了ComPtr會方便不少。關於 COM 可以說的太多,實在沒法在這篇文章說的太細,建議先去閱讀相關資料有點了解了再往下看。

接下來初始化D3D9裝置

// ...

ShowWindow(window, SW_SHOW);

// D3D9
ComPtr<IDirect3D9> d3d9 = Direct3DCreate9(D3D_SDK_VERSION);
ComPtr<IDirect3DDevice9> d3d9Device;

D3DPRESENT_PARAMETERS d3dParams = {};
d3dParams.Windowed = TRUE;
d3dParams.SwapEffect = D3DSWAPEFFECT_DISCARD;
d3dParams.BackBufferFormat = D3DFORMAT::D3DFMT_X8R8G8B8;
d3dParams.Flags = D3DPRESENTFLAG_LOCKABLE_BACKBUFFER;
d3dParams.BackBufferWidth = width;
d3dParams.BackBufferHeight = height;
d3d9->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, window, D3DCREATE_HARDWARE_VERTEXPROCESSING, &d3dParams, d3d9Device.GetAddressOf());

auto currentTime = system_clock::now();
// ...

使用 ComPtr 這個C++模板類去包裝COM指標,就無需操心資源釋放問題了,變數生命週期結束會自動呼叫 Release 釋放資源。

建立裝置最重要的引數是 D3DPRESENT_PARAMETERS 結構,Windowed = TRUE 設定視窗模式,我們現在也不需要全屏。SwapEffect 是交換鏈模式,選 D3DSWAPEFFECT_DISCARD 就行。BackBufferFormat 比較重要,必須選擇 D3DFMT_X8R8G8B8,因為只有他能同時作為後緩衝格式和顯示格式(見下圖),而且 sws_scale 也能正確轉換到這種格式。

image

Flags 必須是 D3DPRESENTFLAG_LOCKABLE_BACKBUFFER,因為待會我們要直接把資料寫入後緩衝,我們不整3D紋理層了。

重新調整下 GetRGBPixels 函式:

void GetRGBPixels(AVFrame* frame, vector<uint8_t>& buffer, AVPixelFormat pixelFormat, int byteCount) {
	AVFrame* swFrame = av_frame_alloc();
	av_hwframe_transfer_data(swFrame, frame, 0);
	frame = swFrame;

	static SwsContext* swsctx = nullptr;
	swsctx = sws_getCachedContext(
		swsctx,
		frame->width, frame->height, (AVPixelFormat)frame->format,
		frame->width, frame->height, pixelFormat, NULL, NULL, NULL, NULL);

	uint8_t* data[] = { &buffer[0] };
	int linesize[] = { frame->width * byteCount };
	sws_scale(swsctx, frame->data, frame->linesize, 0, frame->height, data, linesize);

	av_frame_free(&swFrame);
}

新增了引數 pixelFormat 可以自定義輸出的畫素格式,目的是為了待會輸出 AV_PIX_FMT_BGRA 格式的資料,它對應的正是 D3DFMT_X8R8G8B8,而且不同的格式,每一個畫素佔用位元組數量也不一樣,所以還需要一個 byteCount 參數列示每畫素位元組數。當然 vector<Color_RGB> 我們也不用了,改為通用的 vector<uint8_t>

重新調整 StretchBits 函式:

void StretchBits(IDirect3DDevice9* device, const vector<uint8_t>& bits, int width, int height) {
	ComPtr<IDirect3DSurface9> surface;
	device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, surface.GetAddressOf());

	D3DLOCKED_RECT lockRect;
	surface->LockRect(&lockRect, NULL, D3DLOCK_DISCARD);

	memcpy(lockRect.pBits, &bits[0], bits.size());

	surface->UnlockRect();

	device->Present(NULL, NULL, NULL, NULL);
}

這裡就是把畫面資料寫入後緩衝,然後呼叫 Present 就會顯示在視窗中了。

最後調整 main 函式的一些內容:

// ...

vector<uint8_t> buffer(width * height * 4);

auto window = CreateWindow(className, L"Hello World 標題", WS_OVERLAPPEDWINDOW, 0, 0, decoderParam.width, decoderParam.height, NULL, NULL, hInstance, NULL);
// ...

AVFrame* frame = RequestFrame(decoderParam);

GetRGBPixels(frame, buffer, AVPixelFormat::AV_PIX_FMT_BGRA, 4);

av_frame_free(&frame);

double framerate = (double)vcodecCtx->framerate.den / vcodecCtx->framerate.num;
std::this_thread::sleep_until(currentTime + milliseconds((int)(framerate * 1000)));
currentTime = system_clock::now();

StretchBits(d3d9Device.Get(), buffer, width, height);
// ...

注意buffer的大小有變化,GetRGBPixels 的引數需要使用 AV_PIX_FMT_BGRAStretchBits 改為傳入 d3d9裝置指標。

執行程式,看起來和之前沒啥區別,但其實此時的CPU佔用會稍微降低,而GPU佔用會提升一些。

image

告別 sws_scale

先把視窗調整為無邊框,這樣看起來更酷,也讓畫面的比例稍顯正常:

// ...

auto window = CreateWindow(className, L"Hello World 標題", WS_POPUP, 100, 100, 1280, 720, NULL, NULL, hInstance, NULL);
// ...

image

前面曾經提到,硬解出來的 AVFrame 沒有原始畫面資訊,但我們去看它的format值,會發現對應的是 AV_PIX_FMT_DXVA2_VLD

image

在註釋裡面提到:data[3] 是 一個 LPDIRECT3DSURFACE9,也就是 IDirect3DSurface9*,那我們就可以直接把這個 Surface 呈現到視窗,不需要再把畫面資料從GPU視訊記憶體拷貝回記憶體了,sws_scale 也可以扔了。

我們寫一個新的函式 RenderHWFrame 去做這件事,StretchBitsGetRGBPixels 都不再需要了:

void RenderHWFrame(HWND hwnd, AVFrame* frame) {
	IDirect3DSurface9* surface = (IDirect3DSurface9*)frame->data[3];
	IDirect3DDevice9* device;
	surface->GetDevice(&device);

	ComPtr<IDirect3DSurface9> backSurface;
	device->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, backSurface.GetAddressOf());

	device->StretchRect(surface, NULL, backSurface.Get(), NULL, D3DTEXF_LINEAR);

	device->Present(NULL, NULL, hwnd, NULL);
}

int WINAPI WinMain (
	_In_ HINSTANCE hInstance,
	_In_opt_ HINSTANCE hPrevInstance,
	_In_ LPSTR lpCmdLine,
	_In_ int nShowCmd
) {
// ...

AVFrame* frame = RequestFrame(decoderParam);

double framerate = (double)vcodecCtx->framerate.den / vcodecCtx->framerate.num;
std::this_thread::sleep_until(currentTime + milliseconds((int)(framerate * 1000)));
currentTime = system_clock::now();

RenderHWFrame(window, frame);

av_frame_free(&frame);
// ...

在不同的d3d9裝置之間共享資源是比較麻煩的,所以我們直接獲取到FFmepg建立的d3d9裝置,然後呼叫 Present 的時候指定視窗控制程式碼,就可以讓畫面出現在我們自己的視窗中了。

image

這下子CPU的佔用就真的低到忽略不計了。但此時又出現了一個新的問題,仔細觀察畫面,會發現畫面變糊了,原因就是我們直接使用了FFmpeg的d3d9裝置預設建立的交換鏈,這個交換鏈的解析度相當的低,只有 640x480,具體看他的原始碼就知道了(hwcontext_dxva2.c:46

image

所以我們需要用 FFmpeg 的d3d9裝置建立自己的交換鏈:

void RenderHWFrame(HWND hwnd, AVFrame* frame) {
	IDirect3DSurface9* surface = (IDirect3DSurface9*)frame->data[3];
	IDirect3DDevice9* device;
	surface->GetDevice(&device);

	static ComPtr<IDirect3DSwapChain9> mySwap;
	if (mySwap == nullptr) {
		D3DPRESENT_PARAMETERS params = {};
		params.Windowed = TRUE;
		params.hDeviceWindow = hwnd;
		params.BackBufferFormat = D3DFORMAT::D3DFMT_X8R8G8B8;
		params.BackBufferWidth = frame->width;
		params.BackBufferHeight = frame->height;
		params.SwapEffect = D3DSWAPEFFECT_DISCARD;
		params.BackBufferCount = 1;
		params.Flags = 0;
		device->CreateAdditionalSwapChain(&params, mySwap.GetAddressOf());
	}

	ComPtr<IDirect3DSurface9> backSurface;
	mySwap->GetBackBuffer(0, D3DBACKBUFFER_TYPE_MONO, backSurface.GetAddressOf());

	device->StretchRect(surface, NULL, backSurface.Get(), NULL, D3DTEXF_LINEAR);

	mySwap->Present(NULL, NULL, NULL, NULL, NULL);
}

一個 d3ddevice 是可以擁有多個交換鏈的,使用 CreateAdditionalSwapChain 函式來建立即可,然後就像之前一樣,把硬解得到的 surface 複製到新交換鏈的後緩衝即可。

image

現在即使播放 4k60fps 的視訊,都毫無壓力了。

目前存在的問題

  1. 如果你的螢幕重新整理率是60hz,程式播放60幀視訊的時候,速度比正常的要慢,原因就是 IDirect3DSwapChain9::Present 會強制等待螢幕垂直同步,所以呈現時間總會比正常時間晚一些。
  2. 沒有任何操作控制元件,也不能暫停快進等等。
  3. 沒有聲音。

以上問題我們留到第二篇解決。

相關文章