虽然不搞开发了,但是看到不错的文章还是记录一下把。
转载地址:https://www.cnblogs.com/judgeou/p/14728617.html
前言
起初只是想做一个直接读取视频文件然后播放字符动画的程序。我的设想很简单,只要有现成的库,帮我把视频文件解析成一帧一帧的原始画面信息,那么我只需要读取里面的每一个像素的RGB数值,计算出亮度,然后根据亮度映射到某个字符,再把这些字符全部拼起来显示出来,事情就完成了。于是我就开始研究怎么用 FFmpeg 这个库,网上还是能比较容易找到相关的教程,不久我就把目标实现了。
之后我就想,要不干脆就做一个正经的播放器看看吧,结果,我就遇到了一堆的问题,写这篇文章的目的,就是把这些问题的探索过程,和我自己的答案,分享出来。
因为不打算跨平台,所以没有使用任何构建系统,直接打开Visual Studio 2019新建项目开撸就行。我不打算展现高超的软件工程技巧,以及完美的错误处理,所以代码都是一把梭哈,怎么直接简单怎么来,重点是说清楚这事儿到底怎么干、怎么起步,剩下的事情就交给大家自由发挥了。
本来想一篇写完,后面觉得实在是太长了,特别是后面 DirectX 11 的渲染部分太复杂了,DirectX 9 还算简单,所以第一篇,先把dx9渲染说完,第二篇,再说dx11。
一个简单的窗口
现在都2021年了,实际产品基本不会有人直接用 Win32 API 写 GUI,我之所以还选择这么做,是因为想把底层的东西说明白,但是不想引入太多额外的东西,例如QT、SDL之类的GUI库,况且我也没想过真的要做成一个实用工具。实际上我一开始的版本就是用 SDL 2.0 做的,后面才慢慢脱离,自己写渲染代码。
首先要说的是,在项目属性 - 链接器 - 系统 - 子系统 选择 窗口 (/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;
}
效果:
引入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++目录 中的包含目录和库目录
然后就可以在代码中引入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);
}
}
流程简单来说,就是:
- 获取
AVFormatContext
,这个代表这个视频文件的容器 - 获取
AVStream
,一个视频文件会有多个流,视频流、音频流等等其他资源,我们目前只关注视频流,所以这里有一个判断stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO
- 获取
AVCodec
,代表某个流对应的解码器 - 获取
AVCodecContext
,代表解码器的解码上下文环境 - 进入解码循环,调用用
av_read_frame
获取AVPacket
,判断是否是视频流的数据包,是则调用avcodec_send_packet
发送给AVCodecContext
进行解码,有时一个数据包是不足以解码出完整的一帧画面的,此时就要获取下一个数据包,再次调用avcodec_send_packet
发送到解码器,尝试是否解码成功。 - 最后通过
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);
}
// ...
一个显而易见的问题,就是渲染效率太低了,显示一帧就花了好几秒,对于普通每秒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 的性能诊断工具,实在是太强大了。
可以清晰看到那一句代码,哪一个函数,占用了多少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);
// ...
效果还不错:
接下来稍微优化下代码,在 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最多:
显然 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有一定的占用了:
但还是不够流畅,我们再看看性能分析:
看来是 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
也能正确转换到这种格式。
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_BGRA
,StretchBits
改为传入 d3d9设备指针。
运行程序,看起来和之前没啥区别,但其实此时的CPU占用会稍微降低,而GPU占用会提升一些。
告别 sws_scale
先把窗口调整为无边框,这样看起来更酷,也让画面的比例稍显正常:
// ...
auto window = CreateWindow(className, L"Hello World 标题", WS_POPUP, 100, 100, 1280, 720, NULL, NULL, hInstance, NULL);
// ...
前面曾经提到,硬解出来的 AVFrame 没有原始画面信息,但我们去看它的format值,会发现对应的是 AV_PIX_FMT_DXVA2_VLD
:
在注释里面提到: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 的时候指定窗口句柄,就可以让画面出现在我们自己的窗口中了。
这下子CPU的占用就真的低到忽略不计了。但此时又出现了一个新的问题,仔细观察画面,会发现画面变糊了,原因就是我们直接使用了FFmpeg的d3d9设备默认创建的交换链,这个交换链的分辨率相当的低,只有 640x480,具体看他的源码就知道了(hwcontext_dxva2.c:46)
所以我们需要用 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(¶ms, 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 复制到新交换链的后缓冲即可。
现在即使播放 4k60fps 的视频,都毫无压力了。
目前存在的问题
- 如果你的屏幕刷新率是60hz,程序播放60帧视频的时候,速度比正常的要慢,原因就是
IDirect3DSwapChain9::Present
会强制等待屏幕垂直同步,所以呈现时间总会比正常时间晚一些。 - 没有任何操作控件,也不能暂停快进等等。
- 没有声音。
以上问题我们留到第二篇解决。
前情提要
前篇: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
之后就会看到输出显示:
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
的各个枚举值
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 图形管线有很多阶段,但我们不需要每一阶段都用上,以下就是我们必须编程的阶段:
- Input-Assembler Stage(输入装配)
- Vertex Shader Stage (顶点着色器)
- Rasterizer Stage (光栅化)
- Pixel Shader Stage (像素着色器)
- Output-Merger Stage (输出合并)
完整的管线阶段看这个图(不看也行):
GPU需要经历若干个阶段才能最终熬制1帧画面,每一个阶段都需要上一个阶段的运行结果作为参数输入,同时也可能需要额外加入新的输入参数。
我们新增一个函数 Draw 来实现上面必经阶段:
void Draw(ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain) {
// 顶点输入
// ...
// 顶点索引
// ..
// 顶点着色器
// ...
// 光栅化
// ...
// 像素着色器
// ...
// 输出合并
// ...
// Draw Call
// ...
// 呈现
// ...
}
顶点输入
一个四边形有4个顶点,假设是一个边长为 2 的正方形,中心点坐标是(0,0),那么四个角的坐标很容易就可以得出,如图所示:
但是 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
,那么这个三角形,就前后反了过来,原本的背面会朝着你,于是因为背面剔除导致你看不见这个三角形了。
我们还需要一个命令告诉dx我们画的是三角形
// 告诉系统我们画的是三角形
ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY::D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
顶点着色器
接下来编写顶点着色器,先添加一个顶点着色器文件,就叫 VertexShader.hlsl 吧。
HLSL全称高级着色器语言,和C++语法当然是不一样的,别担心,我们不需要写很复杂的hlsl代码,特别是顶点着色器,几乎什么也不做,直接原样返回顶点坐标即可:
// VertexShader.hlsl
float4 main_VS(float3 pos : POSITION) : SV_POSITION
{
return float4(pos, 1);
}
对着 VertexShader.hlsl 文件右键,点击 属性,调整一些参数:
入口点对应接下来着色器代码的入口函数名,改为 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
把画面呈现到窗口中。下面是运行效果:
修改下左上角的顶点:
const Vertex vertices[] = {
{-0.5, 0.5, 0},
{1, 1, 0},
{1, -1, 0},
{-1, -1, 0},
};
效果不错
如果你最终运行结果是一片黑,那么可能是哪里搞错了,可以看看输出窗口或者使用VS的图形调试看看:
只有一种颜色看起来太单调了,尝试加个渐变效果把,先修改顶点输入的数据:
// 顶点输入
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;
}
顶点着色器的返回类型现在修改为我们自定义的结构体,返回值除了原来的顶点坐标,还添加了纹理坐标,这样我们在像素着色器中就可以接收到它了。在像素着色器中把绿色和蓝色的值,填入纹理坐标的值,效果如图:
注意四个顶点的对应的纹理坐标参数,左上角 绿色和蓝色 都为0,所以是纯红色,越往右,u值增加,绿色越来越多,和红色混合导致越来越黄。越往下,v值增加,蓝色越来越多,和红色混合导致越来越紫。而右下角是纯白色,因为红绿蓝达到最大值。
纹理采样
现在我们有这样一幅图片,大小 32 x 32,接下来尝试把他当作纹理贴到画面中
首先要解析出图片的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)
即可从纹理中取得需要的像素了。
运行效果:
也可以选择不拉伸,而是平铺重复,但这里用不上,我就不一一赘述了。
分离资源创建与渲染过程
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, ¶m.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, ¶m.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), ¶m.pInputLayout);
device->CreateVertexShader(g_main_VS, sizeof(g_main_VS), nullptr, ¶m.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, ¶m.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, ¶m.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, ¶m.pSampler);
// 像素着色器
device->CreatePixelShader(g_main_PS, sizeof(g_main_PS), nullptr, ¶m.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
,依旧看看他的注释:
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, ¶m.texture);
// 创建纹理共享句柄
ComPtr<IDXGIResource> dxgiShareTexture;
param.texture->QueryInterface(__uuidof(IDXGIResource), (void**)dxgiShareTexture.GetAddressOf());
dxgiShareTexture->GetSharedHandle(¶m.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,
¶m.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,
¶m.srvUV
);
// ...
}
创建纹理的时候,Format 注意选择 DXGI_FORMAT_NV12
,和 FFmpeg 解码出来的纹理一致。MiscFlags
设置为 D3D11_RESOURCE_MISC_SHARED
,这样这个纹理才能共享出去。调用 IDXGIResource::GetSharedHandle
可以获得一个句柄,拿着这个句柄,待会就可以用 FFmpeg 的 d3d 设备操作这个纹理了。
根据微软官方的文档描述 DXGI_FORMAT,DXGI_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);
}
}
}
运行结果:
能看到画面,但是全是红色,非常瘆人。
原因是我们没有正确修改 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 值(这个是我在网上抄的)。
最终运行结果:
完美!
很遗憾,目前为止还是没能讲完播放器所有的内容,因为dx11实在太复杂了,直接花了一整篇讲,争取下一篇讲完所有内容。