【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

前情提要

前篇: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 之后就会看到输出显示:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

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 的各个枚举值

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

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 (输出合并)

完整的管线阶段看这个图(不看也行):

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

GPU需要经历若干个阶段才能最终熬制1帧画面,每一个阶段都需要上一个阶段的运行结果作为参数输入,同时也可能需要额外加入新的输入参数。

我们新增一个函数 Draw 来实现上面必经阶段:

void Draw(ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain) {
	// 顶点输入
	// ...

	// 顶点索引
	// ..

	// 顶点着色器
	// ...

	// 光栅化
	// ...

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

	// 输出合并
	// ...

	// Draw Call
	// ...

	// 呈现
	// ...
}

顶点输入

一个四边形有4个顶点,假设是一个边长为 2 的正方形,中心点坐标是(0,0),那么四个角的坐标很容易就可以得出,如图所示:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

但是 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,那么这个三角形,就前后反了过来,原本的背面会朝着你,于是因为背面剔除导致你看不见这个三角形了。

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

我们还需要一个命令告诉dx我们画的是三角形

// 告诉系统我们画的是三角形
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY::D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);

顶点着色器

接下来编写顶点着色器,先添加一个顶点着色器文件,就叫 VertexShader.hlsl 吧。

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

HLSL全称高级着色器语言,和C++语法当然是不一样的,别担心,我们不需要写很复杂的hlsl代码,特别是顶点着色器,几乎什么也不做,直接原样返回顶点坐标即可:

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

对着 VertexShader.hlsl 文件右键,点击 属性,调整一些参数:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

入口点对应接下来着色器代码的入口函数名,改为 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 把画面呈现到窗口中。下面是运行效果:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

修改下左上角的顶点:

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

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

效果不错

如果你最终运行结果是一片黑,那么可能是哪里搞错了,可以看看输出窗口或者使用VS的图形调试看看:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

只有一种颜色看起来太单调了,尝试加个渐变效果把,先修改顶点输入的数据:

// 顶点输入
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;
}

顶点着色器的返回类型现在修改为我们自定义的结构体,返回值除了原来的顶点坐标,还添加了纹理坐标,这样我们在像素着色器中就可以接收到它了。在像素着色器中把绿色和蓝色的值,填入纹理坐标的值,效果如图:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

注意四个顶点的对应的纹理坐标参数,左上角 绿色和蓝色 都为0,所以是纯红色,越往右,u值增加,绿色越来越多,和红色混合导致越来越黄。越往下,v值增加,蓝色越来越多,和红色混合导致越来越紫。而右下角是纯白色,因为红绿蓝达到最大值。

纹理采样

现在我们有这样一幅图片,大小 32 x 32,接下来尝试把他当作纹理贴到画面中

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

首先要解析出图片的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) 即可从纹理中取得需要的像素了。

运行效果:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

也可以选择不拉伸,而是平铺重复,但这里用不上,我就不一一赘述了。

分离资源创建与渲染过程

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,依旧看看他的注释:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

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);
		}
	}
}

运行结果:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

能看到画面,但是全是红色,非常瘆人。

原因是我们没有正确修改 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 值(这个是我在网上抄的)。

最终运行结果:

【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(二)

完美!

很遗憾,目前为止还是没能讲完播放器所有的内容,因为dx11实在太复杂了,直接花了一整篇讲,争取下一篇讲完所有内容。

上一篇:win32 - 找出占用文件的进程id和name


下一篇:如何快速判断一个文件是否为病毒