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

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

前情提要

前篇: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);
}

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

最終執行結果:

image

完美!

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

相關文章