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

雖然不搞開發了,但是看到不錯的文章還是記錄一下把。

轉載地址:https://www.cnblogs.com/judgeou/p/14728617.html

前言

起初只是想做一個直接讀取視頻文件然後播放字符動畫的程序。我的設想很簡單,只要有現成的庫,幫我把視頻文件解析成一幀一幀的原始畫面信息,那麼我只需要讀取裏面的每一個像素的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 函數,把循環解碼的部分單獨抽出來,分解爲兩個函數:InitDecoder 和 RequestFrame

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_DESTROY 和 WM_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 去做這件事,StretchBits 和 GetRGBPixels 都不再需要了:

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. 沒有聲音。

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

前情提要

前篇:https://www.cnblogs.com/judgeou/p/14724951.html

上一集我們攻略了硬件解碼 + Direct3D 9 渲染,這一整篇我們要搞定 Direct3D 11 的渲染,比9複雜的不是一點半點,因爲將會涉及比較完整的圖形管線編程,並且需要編寫簡單的着色器代碼。關於圖形學的內容我不會太深入(我也不懂啊哈哈),僅描述必要知道的知識點。

初始化D3D11

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

ShowWindow(window, SW_SHOW);

// D3D11
DXGI_SWAP_CHAIN_DESC swapChainDesc = {};
auto& bufferDesc = swapChainDesc.BufferDesc;
bufferDesc.Width = clientWidth;
bufferDesc.Height = clientHeight;
bufferDesc.Format = DXGI_FORMAT::DXGI_FORMAT_B8G8R8A8_UNORM;
bufferDesc.RefreshRate.Numerator = 0;
bufferDesc.RefreshRate.Denominator = 0;
bufferDesc.Scaling = DXGI_MODE_SCALING_STRETCHED;
bufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
swapChainDesc.SampleDesc.Count = 1;
swapChainDesc.SampleDesc.Quality = 0;
swapChainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
swapChainDesc.BufferCount = 2;
swapChainDesc.OutputWindow = window;
swapChainDesc.Windowed = TRUE;
swapChainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL;
swapChainDesc.Flags = 0;

UINT flags = 0;

#ifdef DEBUG
flags |= D3D11_CREATE_DEVICE_DEBUG;
#endif // DEBUG

ComPtr<IDXGISwapChain> swapChain;
ComPtr<ID3D11Device> d3ddeivce;
ComPtr<ID3D11DeviceContext> d3ddeviceCtx;
D3D11CreateDeviceAndSwapChain(NULL, D3D_DRIVER_TYPE_HARDWARE, NULL, flags, NULL, NULL, D3D11_SDK_VERSION, &swapChainDesc, &swapChain, &d3ddeivce, NULL, &d3ddeviceCtx);
// ...

d3d11 現在分了三個對象去控制圖形操作,IDXGISwapChain 代表交換鏈,決定了你的畫面分辨率,Present 也是在這個對象上面調用的。ID3D11Device 負責創建資源,例如紋理、Shader、Buffer 等資源。ID3D11DeviceContext 負責下達管線命令。

flags 設置爲 D3D11_CREATE_DEVICE_DEBUG 之後,如果d3d發生異常錯誤之類的,就會在 VS 的輸出窗口直接顯示錯誤的詳細信息,非常方便。

注意:使用 D3D11_CREATE_DEVICE_DEBUG 需要安裝 DirectX SDK,當你發佈到別的電腦中運行時,請去除 D3D11_CREATE_DEVICE_DEBUG,否則會因爲對方沒有調試層而創建d3d設備失敗。現在 DirectX SDK 其實已經木有了,Windows 10 SDK 其實就包含了原來的 DirectX SDK)

例如我把 swapChainDesc.BufferCount 改爲 1,調用 D3D11CreateDeviceAndSwapChain 之後就會看到輸出顯示:

image

DXGI ERROR: IDXGIFactory::CreateSwapChain: Flip model swapchains (DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL and DXGI_SWAP_EFFECT_FLIP_DISCARD) require BufferCount to be between 2 and DXGI_MAX_SWAP_CHAIN_BUFFERS。。。

意思是當使用了 DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL 或者 DXGI_SWAP_EFFECT_FLIP_DISCARD 時,BufferCount 數量必須是 2 至 DXGI_MAX_SWAP_CHAIN_BUFFERS 之間。BufferCount 就是後緩衝數量,增加緩衝數量能防止畫面撕裂,但會加大顯存佔用以及增加延遲。

如果平時有用 PotPlayer,那麼在 視頻渲染器 設置裏面的 Direct3D顯示方式 選項,對應的正是 DXGI_SWAP_EFFECT 的各個枚舉值

image

enum DXGI_SWAP_EFFECT
{
	DXGI_SWAP_EFFECT_DISCARD	= 0,
	DXGI_SWAP_EFFECT_SEQUENTIAL	= 1,
	DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL	= 3,
	DXGI_SWAP_EFFECT_FLIP_DISCARD	= 4
};

如果對相關內容十分感興趣,可以閱讀這篇文章:For best performance, use DXGI flip model。簡單總結,就是請儘可能使用 Flip 模型。

渲染一個四邊形

現在,我們先把FFmpeg放一邊,學學 DirectX 圖形編程,相信我,這就是這篇教程最難的部分,如果你能完全搞明白,後面的部分對你來說絕對是小意思。

Direct3D 11 圖形管線有很多階段,但我們不需要每一階段都用上,以下就是我們必須編程的階段:

  1. Input-Assembler Stage(輸入裝配)
  2. Vertex Shader Stage (頂點着色器)
  3. Rasterizer Stage (光柵化)
  4. Pixel Shader Stage (像素着色器)
  5. Output-Merger Stage (輸出合併)

完整的管線階段看這個圖(不看也行):

image

GPU需要經歷若干個階段才能最終熬製1幀畫面,每一個階段都需要上一個階段的運行結果作爲參數輸入,同時也可能需要額外加入新的輸入參數。

我們新增一個函數 Draw 來實現上面必經階段:

void Draw(ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain) {
	// 頂點輸入
	// ...

	// 頂點索引
	// ..

	// 頂點着色器
	// ...

	// 光柵化
	// ...

	// 像素着色器
	// ...

	// 輸出合併
	// ...

	// Draw Call
	// ...

	// 呈現
	// ...
}

頂點輸入

一個四邊形有4個頂點,假設是一個邊長爲 2 的正方形,中心點座標是(0,0),那麼四個角的座標很容易就可以得出,如圖所示:

image

但是 dx11 不支持直接繪製四邊形,只能選擇繪製三角形,所以我們需要繪製兩個直角三角形,它們拼到一起之後,自然就是一個四邊形了。這個時候,頂點數量就從4個,變成了6個,但有兩個點是完全重合的,dx11 提供了這樣一種功能:你可以先聲明這些點的座標,然後再用數字編號去代替這些點,來表達一個個圖形。對於頂點數量龐大的精細模型可以大量節省顯存,即便我們頂點數量不多,但用這種方式表達起來也比較清晰。

// 頂點輸入
struct Vertex {
	float x; float y; float z;
};

const Vertex vertices[] = {
	{-1,	1,	0},
	{1,	1,	0},
	{1,	-1,	0},
	{-1,	-1,	0},
};

D3D11_BUFFER_DESC bd = {};
bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
bd.ByteWidth = sizeof(vertices);
bd.StructureByteStride = sizeof(Vertex);
D3D11_SUBRESOURCE_DATA sd = {};
sd.pSysMem = vertices;

ComPtr<ID3D11Buffer> pVertexBuffer;
device->CreateBuffer(&bd, &sd, &pVertexBuffer);

UINT stride = sizeof(Vertex);
UINT offset = 0u;
ID3D11Buffer* vertexBuffers[] = { pVertexBuffer.Get() };
ctx->IASetVertexBuffers(0, 1, vertexBuffers, &stride, &offset);

先聲明一個結構體 Vertex,即使我們只准備繪製一個2D圖形,但座標必須得是3D座標,所以z是必須的,保持爲0即可。vertices 變量就是一個 Vertex 數組,裏面一共四個元素,就是四個頂點的座標。先調用ID3D11Device::CreateBuffer 創建好頂點數據,然後調用 ID3D11DeviceContext::IASetVertexBuffers 把他放進管線。

頂點索引

//  頂點索引
const UINT16 indices[] = {
	0,1,2, 0,2,3
};

auto indicesSize = std::size(indices);
D3D11_BUFFER_DESC ibd = {};
ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
ibd.ByteWidth = sizeof(indices);
ibd.StructureByteStride = sizeof(UINT16);
D3D11_SUBRESOURCE_DATA isd = {};
isd.pSysMem = indices;

ComPtr<ID3D11Buffer> pIndexBuffer;
device->CreateBuffer(&ibd, &isd, &pIndexBuffer);
ctx->IASetIndexBuffer(pIndexBuffer.Get(), DXGI_FORMAT_R16_UINT, 0);

indices 裏面的 0,1,2, 0,2,3 就是 vertices 數組的索引,千萬要注意順序,dx 繪製三角形是按照順時針繪製的,如果你把 0,1,2 改爲 0,2,1,那麼這個三角形,就前後反了過來,原本的背面會朝着你,於是因爲背面剔除導致你看不見這個三角形了。

image

我們還需要一個命令告訴dx我們畫的是三角形

// 告訴系統我們畫的是三角形
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY::D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

頂點着色器

接下來編寫頂點着色器,先添加一個頂點着色器文件,就叫 VertexShader.hlsl 吧。

image

HLSL全稱高級着色器語言,和C++語法當然是不一樣的,別擔心,我們不需要寫很複雜的hlsl代碼,特別是頂點着色器,幾乎什麼也不做,直接原樣返回頂點座標即可:

// VertexShader.hlsl
float4 main_VS(float3 pos : POSITION) : SV_POSITION
{
    return float4(pos, 1);
}

對着 VertexShader.hlsl 文件右鍵,點擊 屬性,調整一些參數:

image

image

入口點對應接下來着色器代碼的入口函數名,改爲 main_VS。因爲我們都用 dx11 了,所以着色器模型就選擇 Shader Model 5.0 吧。然後是頭文件名稱改爲 VertexShader.h,這樣着色器編譯後,就會生成一個對應的頭文件,在 main.cpp 裏直接引入即可。

// 頂點着色器
D3D11_INPUT_ELEMENT_DESC ied[] = {
	{"POSITION", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0}
};
ComPtr<ID3D11InputLayout> pInputLayout;
device->CreateInputLayout(ied, std::size(ied), g_main_VS, sizeof(g_main_VS), &pInputLayout);
ctx->IASetInputLayout(pInputLayout.Get());

ComPtr<ID3D11VertexShader> pVertexShader;
device->CreateVertexShader(g_main_VS, sizeof(g_main_VS), nullptr, &pVertexShader);
ctx->VSSetShader(pVertexShader.Get(), 0, 0);

代碼中的 g_main_VS 就是 VertexShader.h 裏的一個變量,代表着色器編譯後的內容,由GPU來執行。

創建頂點着色器不難,關鍵是設置 ID3D11InputLayout 的部分。注意到頂點着色器代碼入口函數的參數:float3 pos : POSITION,這個 POSITION 可以自己命名,但是要和 D3D11_INPUT_ELEMENT_DESC::SemanticName 一致,包括類型 float3 也是和 DXGI_FORMAT_R32G32B32_FLOAT 對應的,設置正確的 InputLayout 就是爲了和着色器的參數正確對應。

光柵化

光柵化更形象的叫法應該是像素化,根據給定的視點,把3D世界轉換爲一幅2D圖像,並且這個圖像的像素數量是有限固定的。

// 光柵化
D3D11_VIEWPORT viewPort = {};
viewPort.TopLeftX = 0;
viewPort.TopLeftY = 0;
viewPort.Width = 1280;
viewPort.Height = 720;
viewPort.MaxDepth = 1;
viewPort.MinDepth = 0;
ctx->RSSetViewports(1, &viewPort);

Width 和 Height 目前和窗口大小相同就行了。

像素着色器

接下來創建一個像素着色器代碼文件:PixelShader.hlsl,屬性設置和 VertexShader.hlsl 類似,就不重複了。

// PixelShader.hlsl
float4 main_PS() : SV_TARGET
{
    float4 pink = float4(1, 0.5, 0.5, 1); // 粉紅色
    return pink;
}

目前我們總是返回一個固定的顏色,粉紅色。這裏注意格式是固定是RGBA,但每個顏色的範圍並不是 0~255,而是 0.0 ~ 1.0。

// 像素着色器
ComPtr<ID3D11PixelShader> pPixelShader;
device->CreatePixelShader(g_main_PS, sizeof(g_main_PS), nullptr, &pPixelShader);
ctx->PSSetShader(pPixelShader.Get(), 0, 0);

我們不需要對這個像素着色器進行額外的參數輸入,所以不需要 InputLayout。

輸出合併

輸出合併階段我們把最終的畫面寫入到後緩衝。

// 輸出合併
ComPtr<ID3D11Texture2D> backBuffer;
swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&backBuffer);

CD3D11_RENDER_TARGET_VIEW_DESC renderTargetViewDesc(D3D11_RTV_DIMENSION_TEXTURE2D, DXGI_FORMAT_R8G8B8A8_UNORM);
ComPtr<ID3D11RenderTargetView>  rtv;
device->CreateRenderTargetView(backBuffer.Get(), &renderTargetViewDesc, &rtv);
ID3D11RenderTargetView* rtvs[] = { rtv.Get() };
ctx->OMSetRenderTargets(1, rtvs, nullptr);

OMSetRenderTargets 不能直接操作 ID3D11Texture2D,需要一箇中間層 ID3D11RenderTargetView 來實現。把 ID3D11RenderTargetView 綁定到後緩衝,然後調用 OMSetRenderTargets 把畫面往 ID3D11RenderTargetView 輸出即可。

最終呈現

// Draw Call
ctx->DrawIndexed(indicesSize, 0, 0);

// 呈現
swapchain->Present(1, 0);

最終調用 DrawIndexed 顯卡就會開始運算,參數 indicesSize 就是頂點數量(6個,包括重複的頂點),調用 Present 把畫面呈現到窗口中。下面是運行效果:

image

修改下左上角的頂點:

const Vertex vertices[] = {
	{-0.5,	0.5,	0},
	{1,		1,	0},
	{1,		-1,	0},
	{-1,	-1,	0},
};

image

效果不錯

如果你最終運行結果是一片黑,那麼可能是哪裏搞錯了,可以看看輸出窗口或者使用VS的圖形調試看看:

image

image

只有一種顏色看起來太單調了,嘗試加個漸變效果把,先修改頂點輸入的數據:

// 頂點輸入
struct Vertex {
	float x; float y; float z;
	struct
	{
		float u;
		float v;
	} tex;
};

const Vertex vertices[] = {
	{-1,	1,	0,	0,	0},
	{1,	1,	0,	1,	0},
	{1,	-1,	0,	1,	1},
	{-1,	-1,	0,	0,	1},
};

// ...

// 頂點着色器
D3D11_INPUT_ELEMENT_DESC ied[] = {
	{"POSITION", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
	{"TEXCOORD", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0}
};

注意 vertices 現在除了xyz座標外,還多了兩個uv值,u 對應橫座標,v 對應縱座標,這是用來描述紋理座標的,待會就來體會他的作用。

然後再修改 VertexShader.hlsl:

// VertexShader.hlsl
struct VSOut
{
    float2 tex : TEXCOORD;
    float4 pos : SV_POSITION;
};

VSOut main_VS(float3 pos : POSITION, float2 tex : TEXCOORD)
{
    VSOut vsout;
    vsout.pos = float4(pos.x, pos.y, pos.z, 1);
    vsout.tex = tex;
    return vsout;
}

main_VS 添加一個新的參數 tex,因此 InputLayout 也要有變化,特別注意 ied 第二個元素的 AlignedByteOffset 是上一個元素的字節大小,也就是 DXGI_FORMAT_R32G32B32_FLOAT 的字節大小 12 字節。

修改一下 PixelShader.hlsl

// PixelShader.hlsl

float4 main_PS(float2 tc : TEXCOORD) : SV_TARGET
{
    float4 color = float4(1, tc.x, tc.y, 1);
    return color;
}

頂點着色器的返回類型現在修改爲我們自定義的結構體,返回值除了原來的頂點座標,還添加了紋理座標,這樣我們在像素着色器中就可以接收到它了。在像素着色器中把綠色和藍色的值,填入紋理座標的值,效果如圖:

image

注意四個頂點的對應的紋理座標參數,左上角 綠色和藍色 都爲0,所以是純紅色,越往右,u值增加,綠色越來越多,和紅色混合導致越來越黃。越往下,v值增加,藍色越來越多,和紅色混合導致越來越紫。而右下角是純白色,因爲紅綠藍達到最大值。

紋理採樣

現在我們有這樣一幅圖片,大小 32 x 32,接下來嘗試把他當作紋理貼到畫面中

image

首先要解析出圖片的RGBA數據,這個我已經做好了(star.h),數據寫在一個頭文件裏面,直接拿來用,就不用再寫其他讀取圖片文件的代碼了。

// 紋理創建
ComPtr<ID3D11Texture2D> texture;
D3D11_TEXTURE2D_DESC tdesc = {};
tdesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
tdesc.Width = 32;
tdesc.Height = 32;
tdesc.ArraySize = 1;
tdesc.MipLevels = 1;
tdesc.SampleDesc = { 1, 0 };
tdesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;

D3D11_SUBRESOURCE_DATA tdata = { STAR_RGBA_DATA, 32 * 4, 0};

device->CreateTexture2D(&tdesc, &tdata, &texture);

注意 Format 選擇 DXGI_FORMAT_R8G8B8A8_UNORM,Width 和 Height 與圖片實際大小保持一致,BindFlags 選擇 D3D11_BIND_SHADER_RESOURCE,因爲待會着色器需要訪問紋理。

// 創建着色器資源視圖
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = CD3D11_SHADER_RESOURCE_VIEW_DESC(
	texture.Get(),
	D3D11_SRV_DIMENSION_TEXTURE2D,
	DXGI_FORMAT_R8G8B8A8_UNORM
);
ComPtr<ID3D11ShaderResourceView> srv;
device->CreateShaderResourceView(texture.Get(), &srvDesc, &srv);

着色器不能直接訪問紋理,需要經過一箇中間層 ID3D11ShaderResourceView,因此需要創建它。

// 創建採樣器
D3D11_SAMPLER_DESC samplerDesc = {};
samplerDesc.Filter = D3D11_FILTER::D3D11_FILTER_ANISOTROPIC;
samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
samplerDesc.MaxAnisotropy = 16;
ComPtr<ID3D11SamplerState> pSampler;
device->CreateSamplerState(&samplerDesc, &pSampler);

採樣器的作用是根據紋理座標從紋理中提取像素。例如這個星星圖片像素只有 32x32,但是最後卻要顯示在一個 1280x720 分辨率的四邊形中,像素不可能一一對應,而採樣器能夠生成合適中間過度像素。D3D11_FILTER_ANISOTROPIC 就是各向異性過濾,MaxAnisotropy 是倍數,設置16就行。

// 像素着色器
ComPtr<ID3D11PixelShader> pPixelShader;
device->CreatePixelShader(g_main_PS, sizeof(g_main_PS), nullptr, &pPixelShader);
ctx->PSSetShader(pPixelShader.Get(), 0, 0);
ID3D11ShaderResourceView* srvs[] = { srv.Get() };
ctx->PSSetShaderResources(0, 1, srvs);
ID3D11SamplerState* samplers[] = { pSampler.Get() };
ctx->PSSetSamplers(0, 1, samplers);

這裏把着色器資源視圖和採樣器放進管線,接着修改 PixelShader.hlsl:

// PixelShader.hlsl
Texture2D<float4> starTexture : t0;

SamplerState splr;

float4 main_PS(float2 tc : TEXCOORD) : SV_TARGET
{
    float4 color = starTexture.Sample(splr, tc);
    return color;
}

starTexture 可以由用戶命名,t0 的作用是聲明這是第一個紋理,如果有多個紋理就是接着 t1、t2、t3 即可。因爲我們只設置了一個採樣器,所以直接寫 SamplerState splr 即可。調用 starTexture.Sample(splr, tc) 即可從紋理中取得需要的像素了。

運行效果:

image

也可以選擇不拉伸,而是平鋪重複,但這裏用不上,我就不一一贅述了。

分離資源創建與渲染過程

Draw 函數目前包含了 DirectX 資源的創建操作,比如 CreateTexture2D CreateBuffer 等等,這些操作可以單獨提取出來,沒有必要每次循環都重新創建這些資源。

void InitScence(ID3D11Device* device, ScenceParam& param) {
	// 頂點輸入
	const Vertex vertices[] = {
		{-1,	1,	0,	0,	0},
		{1,		1,	0,	1,	0},
		{1,		-1,	0,	1,	1},
		{-1,	-1,	0,	0,	1},
	};

	D3D11_BUFFER_DESC bd = {};
	bd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
	bd.ByteWidth = sizeof(vertices);
	bd.StructureByteStride = sizeof(Vertex);
	D3D11_SUBRESOURCE_DATA sd = {};
	sd.pSysMem = vertices;

	device->CreateBuffer(&bd, &sd, &param.pVertexBuffer);

	D3D11_BUFFER_DESC ibd = {};
	ibd.BindFlags = D3D11_BIND_INDEX_BUFFER;
	ibd.ByteWidth = sizeof(param.indices);
	ibd.StructureByteStride = sizeof(UINT16);
	D3D11_SUBRESOURCE_DATA isd = {};
	isd.pSysMem = param.indices;

	device->CreateBuffer(&ibd, &isd, &param.pIndexBuffer);

	// 頂點着色器
	D3D11_INPUT_ELEMENT_DESC ied[] = {
		{"POSITION", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32B32_FLOAT, 0, 0, D3D11_INPUT_PER_VERTEX_DATA, 0},
		{"TEXCOORD", 0, DXGI_FORMAT::DXGI_FORMAT_R32G32_FLOAT, 0, 12, D3D11_INPUT_PER_VERTEX_DATA, 0}
	};

	device->CreateInputLayout(ied, std::size(ied), g_main_VS, sizeof(g_main_VS), &param.pInputLayout);
	device->CreateVertexShader(g_main_VS, sizeof(g_main_VS), nullptr, &param.pVertexShader);

	// 紋理創建
	D3D11_TEXTURE2D_DESC tdesc = {};
	tdesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
	tdesc.Width = 32;
	tdesc.Height = 32;
	tdesc.ArraySize = 1;
	tdesc.MipLevels = 1;
	tdesc.SampleDesc = { 1, 0 };
	tdesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
	D3D11_SUBRESOURCE_DATA tdata = { STAR_RGBA_DATA, 32 * 4, 0 };

	device->CreateTexture2D(&tdesc, &tdata, &param.texture);

	// 創建着色器資源
	D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc = CD3D11_SHADER_RESOURCE_VIEW_DESC(
		param.texture.Get(),
		D3D11_SRV_DIMENSION_TEXTURE2D,
		DXGI_FORMAT_R8G8B8A8_UNORM
	);

	device->CreateShaderResourceView(param.texture.Get(), &srvDesc, &param.srv);

	// 創建採樣器
	D3D11_SAMPLER_DESC samplerDesc = {};
	samplerDesc.Filter = D3D11_FILTER::D3D11_FILTER_ANISOTROPIC;
	samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_WRAP;
	samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_WRAP;
	samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP;
	samplerDesc.MaxAnisotropy = 16;

	device->CreateSamplerState(&samplerDesc, &param.pSampler);

	// 像素着色器
	device->CreatePixelShader(g_main_PS, sizeof(g_main_PS), nullptr, &param.pPixelShader);
}

void Draw(ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain, ScenceParam& param) {
	UINT stride = sizeof(Vertex);
	UINT offset = 0u;
	ID3D11Buffer* vertexBuffers[] = { param.pVertexBuffer.Get() };
	ctx->IASetVertexBuffers(0, 1, vertexBuffers, &stride, &offset);

	ctx->IASetIndexBuffer(param.pIndexBuffer.Get(), DXGI_FORMAT_R16_UINT, 0);

	ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY::D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

	ctx->IASetInputLayout(param.pInputLayout.Get());

	ctx->VSSetShader(param.pVertexShader.Get(), 0, 0);

	// 光柵化
	D3D11_VIEWPORT viewPort = {};
	viewPort.TopLeftX = 0;
	viewPort.TopLeftY = 0;
	viewPort.Width = 1280;
	viewPort.Height = 720;
	viewPort.MaxDepth = 1;
	viewPort.MinDepth = 0;
	ctx->RSSetViewports(1, &viewPort);

	ctx->PSSetShader(param.pPixelShader.Get(), 0, 0);
	ID3D11ShaderResourceView* srvs[] = { param.srv.Get() };
	ctx->PSSetShaderResources(0, 1, srvs);
	ID3D11SamplerState* samplers[] = { param.pSampler.Get() };
	ctx->PSSetSamplers(0, 1, samplers);

	// 輸出合併
	ComPtr<ID3D11Texture2D> backBuffer;
	swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&backBuffer);

	CD3D11_RENDER_TARGET_VIEW_DESC renderTargetViewDesc(D3D11_RTV_DIMENSION_TEXTURE2D, DXGI_FORMAT_R8G8B8A8_UNORM);
	ComPtr<ID3D11RenderTargetView>  rtv;
	device->CreateRenderTargetView(backBuffer.Get(), &renderTargetViewDesc, &rtv);
	ID3D11RenderTargetView* rtvs[] = { rtv.Get() };
	ctx->OMSetRenderTargets(1, rtvs, nullptr);

	// Draw Call
	auto indicesSize = std::size(param.indices);
	ctx->DrawIndexed(indicesSize, 0, 0);

	// 呈現
	swapchain->Present(1, 0);
}

InitScence 負責創建 DirectX 資源,Draw 僅負責執行渲染指令。

再稍微修改 main 函數:

// ...

D3D11CreateDeviceAndSwapChain(NULL, D3D_DRIVER_TYPE_HARDWARE, NULL, flags, NULL, NULL, D3D11_SDK_VERSION, &swapChainDesc, &swapChain, &d3ddeivce, NULL, &d3ddeviceCtx);

ScenceParam scenceParam;
InitScence(d3ddeivce.Get(), scenceParam);

auto currentTime = system_clock::now();

MSG msg;
while (1) {
	// ...
	if (hasMsg) {
		// ...
	}
	else {
		Draw(d3ddeivce.Get(), d3ddeviceCtx.Get(), swapChain.Get(), scenceParam);
	}
}
// ...

D3D11VA 硬件解碼

好了,最困難的部分已經過去,終於可以回到 FFmpeg 的部分了。之前硬件解碼使用的設備類型是 AV_HWDEVICE_TYPE_DXVA2,這回換成 AV_HWDEVICE_TYPE_D3D11VA

// 啓用硬件解碼器
AVBufferRef* hw_device_ctx = nullptr;
av_hwdevice_ctx_create(&hw_device_ctx, AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA, NULL, NULL, NULL);
vcodecCtx->hw_device_ctx = hw_device_ctx;

觀察解碼出來的 AVFrame::format,是 AV_PIX_FMT_D3D11,依舊看看他的註釋:

image

data[0] 是一個 ID3D11Texture2D,這就是爲什麼前面要大費周章講這麼多,爲的就是說明紋理如何最終顯示在屏幕上。註釋還提到了 data[1] 是紋理數組的索引,事實上 ID3D11Texture2D 可以存儲多個紋理,待會我們把 data[0] 的紋理複製出來的時候就要用到這個索引值。

現在的問題是,不同的 d3d11device 之間的 ID3D11Texture2D,是沒法直接訪問的,因此需要做一些操作實現紋理共享。

struct ScenceParam {
	// ...
	ComPtr<ID3D11Texture2D> texture;
	HANDLE sharedHandle;
	ComPtr<ID3D11ShaderResourceView> srvY;
	ComPtr<ID3D11ShaderResourceView> srvUV;
	// ...
};

在 ScenceParam 結構體添加一個 HANDLE sharedHandle,存儲共享句柄。再添加兩個着色器資源視圖:srvY 和 srvUV。

void InitScence(ID3D11Device* device, ScenceParam& param, const DecoderParam& decoderParam) {
	// ...

	// 紋理創建
	D3D11_TEXTURE2D_DESC tdesc = {};
	tdesc.Format = DXGI_FORMAT_NV12;
	tdesc.Usage = D3D11_USAGE_DEFAULT;
	tdesc.MiscFlags = D3D11_RESOURCE_MISC_SHARED;
	tdesc.ArraySize = 1;
	tdesc.MipLevels = 1;
	tdesc.SampleDesc = { 1, 0 };
	tdesc.Height = decoderParam.height;
	tdesc.Width = decoderParam.width;
	tdesc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
	device->CreateTexture2D(&tdesc, nullptr, &param.texture);

	// 創建紋理共享句柄
	ComPtr<IDXGIResource> dxgiShareTexture;
	param.texture->QueryInterface(__uuidof(IDXGIResource), (void**)dxgiShareTexture.GetAddressOf());
	dxgiShareTexture->GetSharedHandle(&param.sharedHandle);

	// 創建着色器資源
	D3D11_SHADER_RESOURCE_VIEW_DESC const YPlaneDesc = CD3D11_SHADER_RESOURCE_VIEW_DESC(
		param.texture.Get(),
		D3D11_SRV_DIMENSION_TEXTURE2D,
		DXGI_FORMAT_R8_UNORM
	);

	device->CreateShaderResourceView(
		param.texture.Get(),
		&YPlaneDesc,
		&param.srvY
	);

	D3D11_SHADER_RESOURCE_VIEW_DESC const UVPlaneDesc = CD3D11_SHADER_RESOURCE_VIEW_DESC(
		param.texture.Get(),
		D3D11_SRV_DIMENSION_TEXTURE2D,
		DXGI_FORMAT_R8G8_UNORM
	);

	device->CreateShaderResourceView(
		param.texture.Get(),
		&UVPlaneDesc,
		&param.srvUV
	);
	// ...
}

創建紋理的時候,Format 注意選擇 DXGI_FORMAT_NV12,和 FFmpeg 解碼出來的紋理一致。MiscFlags 設置爲 D3D11_RESOURCE_MISC_SHARED,這樣這個紋理才能共享出去。調用 IDXGIResource::GetSharedHandle 可以獲得一個句柄,拿着這個句柄,待會就可以用 FFmpeg 的 d3d 設備操作這個紋理了。

根據微軟官方的文檔描述 DXGI_FORMATDXGI_FORMAT_NV12 紋理格式應當使用兩個着色器資源視圖去處理,一個視圖的格式是 DXGI_FORMAT_R8_UNORM,對應Y通道,一個視圖的格式是 DXGI_FORMAT_R8G8_UNORM,對應UV通道,所以這裏需要創建兩個着色器資源視圖。後面調用 PSSetShaderResources 時,把兩個視圖都放進管線:

void Draw(ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain, ScenceParam& param) {
// ...

ID3D11ShaderResourceView* srvs[] = { param.srvY.Get(), param.srvUV.Get() };
ctx->PSSetShaderResources(0, std::size(srvs), srvs);
// ...

}

編寫一個新函數 UpdateVideoTexture 把 FFmpeg 解碼出來的紋理複製到我們自己創建的紋理中:

void UpdateVideoTexture(AVFrame* frame, const ScenceParam& scenceParam, const DecoderParam& decoderParam) {
	ID3D11Texture2D* t_frame = (ID3D11Texture2D*)frame->data[0];
	int t_index = (int)frame->data[1];

	ComPtr<ID3D11Device> device;
	t_frame->GetDevice(device.GetAddressOf());

	ComPtr<ID3D11DeviceContext> deviceCtx;
	device->GetImmediateContext(&deviceCtx);

	ComPtr<ID3D11Texture2D> videoTexture;
	device->OpenSharedResource(scenceParam.sharedHandle, __uuidof(ID3D11Texture2D), (void**)&videoTexture);

	deviceCtx->CopySubresourceRegion(videoTexture.Get(), 0, 0, 0, 0, t_frame, t_index, 0);
	deviceCtx->Flush();
}

ID3D11Device::OpenSharedResource 可以通過剛剛創建的共享句柄打開由我們創建的紋理,再調用 CopySubresourceRegion 把 FFmpeg 的紋理複製過來。最後注意必須要調用 Flush,強制 GPU 清空當前命令緩衝區,否則可能會出現畫面一閃一閃,看到綠色幀的問題(不一定每臺電腦都可能發生)。

最後修改 main 函數

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

	DecoderParam decoderParam;
	ScenceParam scenceParam;

	InitDecoder(filePath.c_str(), decoderParam);
	// ...
	
	InitScence(d3ddeivce.Get(), scenceParam, decoderParam);
	// ...

	MSG msg;
	while (1) {
		// ...
		if (hasMsg) {
			// ...
		}
		else {
			auto frame = RequestFrame(decoderParam);
			UpdateVideoTexture(frame, scenceParam, decoderParam);
			Draw(d3ddeivce.Get(), d3ddeviceCtx.Get(), swapChain.Get(), scenceParam);
			av_frame_free(&frame);
		}
	}
}

運行結果:

image

能看到畫面,但是全是紅色,非常瘮人。

原因是我們沒有正確修改 PixelShader.hlsl,現在第一個着色器資源不再是 Texture2D<float4> 類型了,而應該是 Texture2D<float>,就是Y通道。此時程序運行並不會出現錯誤提示,而是會進行一個類型轉換,直接把 float 轉換成 float4,比如 float(1) 會變成 float4(1, 0, 0, 0),導致Y通道的數值落在了紅色上(RGBA,R是第一個),因此我們看到的畫面就只有紅色了。下面修改爲正確的代碼:

// PixelShader.hlsl
Texture2D<float> yChannel : t0;
Texture2D<float2> uvChannel : t1;

SamplerState splr;

static const float3x3 YUVtoRGBCoeffMatrix =
{
	1.164383f, 1.164383f, 1.164383f,
	0.000000f, -0.391762f, 2.017232f,
	1.596027f, -0.812968f, 0.000000f
};

float3 ConvertYUVtoRGB(float3 yuv)
{
	// Derived from https://msdn.microsoft.com/en-us/library/windows/desktop/dd206750(v=vs.85).aspx
	// Section: Converting 8-bit YUV to RGB888

	// These values are calculated from (16 / 255) and (128 / 255)
	yuv -= float3(0.062745f, 0.501960f, 0.501960f);
	yuv = mul(yuv, YUVtoRGBCoeffMatrix);

	return saturate(yuv);
}

float4 main_PS(float2 tc : TEXCOORD) : SV_TARGET
{
    float y = yChannel.Sample(splr, tc);
    float2 uv = uvChannel.Sample(splr, tc);
    float3 rgb = ConvertYUVtoRGB(float3(y, uv));
    return float4(rgb, 1);
}

看起來我們有兩個紋理:yChannel 和 uvChannel,但其實只是對同一個紋理的兩種讀取方式而已。還記得前面提到的 YUV420P 的採樣方式嗎,4個Y共用一個UV,這裏採樣器非常巧妙的完成了這項工作,根據紋理座標提取了合適的數值。最後 ConvertYUVtoRGB 函數把 yuv 轉換爲 rgb 值(這個是我在網上抄的)。

最終運行結果:

image

完美!

很遺憾,目前爲止還是沒能講完播放器所有的內容,因爲dx11實在太複雜了,直接花了一整篇講,爭取下一篇講完所有內容。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章