资讯详情

MFC视频播放器开发:FFmpeg解码与D3D渲染实战指南

📅 2026/9/16 12:28:59 | 华诺云谱 👁 阅读
MFC视频播放器开发:FFmpeg解码与D3D渲染实战指南
简介面向需要将FFmpeg解码与Windows图形渲染结合落地的MFC开发者这份工程源码演示了一款完整音视频播放器的实现路径通过FFmpeg完成拉流解码D3D负责视频渲染DirectSound播放音频并集成libjpeg截图、Mp4Record录像以及码流信息叠加显示等功能。代码基于VS2019组织层次清晰适合中高级开发者参考多媒体管线搭建与界面联调。资源包共565个文件以277个h头文件、73个c源文件为主体另含lib/dll依赖库、sln/vcxproj工程文件、txt说明文档及少量jpg示例图整体33.86MB便于按模块查阅。已有127人学习浏览。读者可获得可直接编译的播放器框架以及截图、录像、音频输出、文字叠加的具体实现代码为二次开发或学习FFmpeg/D3D集成提供完整示例。1. 为什么 FFmpegD3D 在 MFC 里仍是做播放器的主流组合MFC 的老式对话框界面在工控机、采集盒、内网设备管理工具里依然大量存在给这种界面加“拉流、解码、播放、录像”能力时最容易走的一条死路是拿到解码帧后用 GDI 直接往窗口上贴图。BitBlt 在 1080p 30fps 下 CPU 占用持续走高窗口拉伸时闪烁和撕裂明显想在画面上叠加码流信息还得再单独刷一层文本。把 FFmpeg 负责网络拉流和音视频解码、D3D 负责视频渲染与文本叠加是这类工程里非常常见的分工方式解码线程拿 AVFrame渲染线程用 D3D9 纹理做贴图音频帧交给 DirectSound 播放截图用 libjpeg 输出 JPG录像走封装好的 Mp4Record 类。下面就把这条链路拆开讲适合正在 VS2019 下维护或新写 MFC 视频工具的 C 工程师。2. FFmpeg 拉流与解码从 AVFormatContext 到 AVFrame 的完整管线这部分的输入是 URLRTSP、RTMP 或本地文件输出是解码后的视频帧和音频 PCM。在写代码之前我会先用命令行工具确认流里面的编码格式和分辨率ffprobe -show_streams返回的 codec_name、width、height 字段就是后面avcodec_find_decoder与 D3D 渲染初始化要填的参数。用命令行摸清输入再动工程代码可以省掉很多调试时“解码出来只有声音没画面”的来回排查。2.1 打开输入流avformat_open_input 与超时参数在 MFC 工程里拉流地址通常来自界面上的编辑框可能是 RTSP、RTMP也可能是本地 mp4 文件。打开流的第一步是准备AVFormatContext把 URL 传进去。网络流的超时不能依赖默认值否则设备掉线时avformat_open_input会卡住几十秒界面看起来像死掉一样。AVFormatContext* fmt_ctx nullptr; AVDictionary* opts nullptr; if (strncmp(url, rtsp://, 7) 0) { av_dict_set(opts, rtsp_transport, tcp, 0); // UDP 丢包会导致马赛克 av_dict_set(opts, max_delay, 500000, 0); // 500ms 缓冲 av_dict_set(opts, timeout, 3000000, 0); // 3 秒连接超时单位微秒 } int ret avformat_open_input(fmt_ctx, url, nullptr, opts); av_dict_free(opts); if (ret ! 0) { char err[128] { 0 }; av_strerror(ret, err, sizeof(err)); AfxMessageBox(err); // 弹窗提示比静默失败更利于现场排查 }av_dict_set设置的参数最终会进入底层协议实现RTSP 场景下rtsp_transport建议固定为 tcp因为默认的 UDP 在弱网环境会大量丢包解码器会持续报错。max_delay的单位是微秒调小会降低延迟但抖动也更明显工控局域网里 300000 到 500000 是常用区间。timeout只对连接阶段生效流建立之后的断流检测要放到解码循环里做。之后调用avformat_find_stream_info它通过读取若干帧来填充streams数组再配合av_find_best_stream取视频流索引。ret avformat_find_stream_info(fmt_ctx, nullptr); if (ret 0) { return; } int video_stream av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (video_stream 0) { return; } AVStream* st fmt_ctx-streams[video_stream]; const AVCodec* dec avcodec_find_decoder(st-codecpar-codec_id); AVCodecContext* dec_ctx avcodec_alloc_context3(dec); avcodec_parameters_to_context(dec_ctx, st-codecpar); ret avcodec_open2(dec_ctx, dec, nullptr);这里必须走avcodec_parameters_to_context不要直接把codecpar指针对拷给解码器。新版 FFmpeg 里AVStream-codec已经废弃直接访问虽然能编过但运行时字段是空的会导致打开解码器失败。音频流用同样的方式拿到audio_stream和audio_ctx后面 DirectSound 播放时需要根据sample_rate、ch_layout.nb_channels来构造波形格式。在 FFmpeg 5.x 以上channels字段被ch_layout取代取值时注意版本差异。函数职责容易踩的坑avformat_open_input打开网络流或本地文件网络异常时卡住需配 timeoutavformat_find_stream_info探测流参数对某些 RTSP 源耗时较长av_find_best_stream按媒体类型取流索引返回值为负表示未找到avcodec_find_decoder根据 codec_id 取解码器codec_id 未知时返回空avcodec_open2打开解码器上下文像素格式不支持时直接失败2.2 解码线程的主循环读包、送包、取帧打开解码器后进入主循环。为了不让 UI 线程卡顿这个循环通常会放在AfxBeginThread创建的线程里循环用is_playing布尔值控制退出。每轮av_read_frame读到一个AVPacket按stream_index分流给视频或音频解码器。要注意的是avcodec_send_packet和avcodec_receive_frame并不是一一对应必须用 while 循环把缓冲帧全部取完否则会出现“画面比声音慢半拍”的假象。AVPacket* pkt av_packet_alloc(); AVFrame* frame av_frame_alloc(); while (playing) { int ret av_read_frame(fmt_ctx, pkt); if (ret 0) { OnStreamDropped(); break; } // 断流或文件读完 if (pkt-stream_index video_stream) { ret avcodec_send_packet(dec_ctx, pkt); if (ret 0 || ret AVERROR(EAGAIN)) { while (avcodec_receive_frame(dec_ctx, frame) 0) { // 拿到解码后的 AVFrame交给渲染线程投递 PostRenderFrame(frame); } } } else if (audio_ctx pkt-stream_index audio_stream) { // 音频路径解码得到的 PCM 写入 DirectSound 缓冲 HandleAudioPacket(audio_ctx, pkt); } av_packet_unref(pkt); } av_frame_free(frame); av_packet_free(pkt);av_read_frame返回负数表示读完或断流RTSP 场景下通常要做 23 秒的重连而不是立刻退出线程。视频分支里先send_packet再循环receive_frame是因为解码器自带帧缓冲特别是存在 B 帧时一次 send 可能对应多次 receive。这个循环里最容易犯的错是漏调av_packet_unrefpacket 内部引用计数不释放跑几个小时后内存持续上涨。解码循环处理完的 AVFrame如果不想深拷贝就直接把指针交给渲染线程但要注意同一个 frame 不能被两个线程同时读写工程里的常见做法是维护帧队列渲染线程从队列取帧并负责av_frame_unref。音频同步上我一般不会在解码循环里做复杂等待而是让视频渲染线程按 PTS 对齐。DirectSound 那一侧维护当前播放位置视频帧到了之后比较frame-pts与音频时钟的差值大于阈值就丢帧小于阈值就等一小段。单纯用Sleep(40)锁帧率的方式在 RTSP 源上误差很大因为网络抖动会让实际帧间隔在 20ms 到 80ms 之间摆动。把AV_NOPTS_VALUE和 PTS 跳变的情况记录到日志里比肉眼看画面卡顿更容易定位延迟源头。3. D3D 渲染与码流信息叠加纹理、顶点缓冲与文本绘制解码拿到的 AVFrame 是 YUV 数据D3D 不能直接显示。这一步有两个方向一是先经sws_scale转成 RGB32再拷贝到 D3D 纹理绘制一个全屏四边形二是把 Y、U、V 三个 plane 分别放到三张纹理里用像素着色器合成。源码里用的是软件转换 单纹理的方式CPU 开销可控而且对显卡几乎没有要求在工控机和老设备上更稳。3.1 初始化 D3D9 与创建纹理纯软件渲染在 1080p 满帧播放时 CPU 占用很高D3D9 的做法是建一块和窗口尺寸匹配的纹理把解码后的画面先转成 RGB32拷贝进纹理再画一个全屏大小的四边形纹理坐标固定为 01。相比StretchRect贴合窗口用顶点绘制的灵活度更高还能顺带做画面旋转和缩放。初始化时的关键参数如下。D3DPRESENT_PARAMETERS pp { 0 }; pp.Windowed TRUE; pp.SwapEffect D3DSWAPEFFECT_DISCARD; // 窗口模式常用交换效果 pp.BackBufferFormat D3DFMT_UNKNOWN; // 由系统匹配窗口格式 pp.BackBufferCount 1; pp.PresentationInterval D3DPRESENT_INTERVAL_ONE; // 垂直同步减少撕裂 LPDIRECT3D9 d3d Direct3DCreate9(D3D_SDK_VERSION); LPDIRECT3DDEVICE9 device nullptr; d3d-CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, pp, device);D3DCREATE_SOFTWARE_VERTEXPROCESSING对顶点处理要求不高兼容性最好。D3DPRESENT_INTERVAL_ONE表示每帧都等垂直同步画面更稳定如果是低延迟监看可以改成D3DPRESENT_INTERVAL_IMMEDIATE但撕裂会明显一些。硬件比较强的机器可以换成D3DCREATE_HARDWARE_VERTEXPROCESSING4K 输入时能看出差别。纹理创建和拷贝是渲染线程每帧都要执行的。LockRect返回的Pitch是显存里的行跨度不能假设等于width * 4按行拷贝时必须用Pitch作步长。LPDIRECT3DTEXTURE9 tex nullptr; device-CreateTexture(frame_w, frame_h, 1, D3DUSAGE_DYNAMIC, D3DFMT_X8R8G8B8, D3DPOOL_DEFAULT, tex, nullptr); D3DLOCKED_RECT rect; tex-LockRect(0, rect, nullptr, D3DLOCK_DISCARD); for (int y 0; y frame_h; y) { memcpy((BYTE*)rect.pBits y * rect.Pitch, rgb_buf y * src_pitch, frame_w * 4); } tex-UnlockRect(0);D3DLOCK_DISCARD告诉驱动这块纹理内容不作保留可以跳过拷贝旧数据的开销。src_pitch是sws_scale输出 buffer 的行长未必和width*4相等建议直接用av_image_fill_arrays得到的 linesize。把纹理更新和画画分开的好处是渲染循环里不需要每次都重新创建纹理只要窗口尺寸或输入分辨率变化时才重建。绘制环节用DrawPrimitiveUP提交一个三角形带顶点坐标是窗口宽高纹理坐标固定为 01这样窗口拉伸时画面自动缩放。想保持原始宽高比时在计算顶点坐标时加上黑边偏移即可。device-BeginScene(); device-SetTexture(0, tex); device-SetFVF(D3DFVF_XYZRHW | D3DFVF_TEX1); struct VERTEX { float x, y, z, rhw, u, v; }; VERTEX v[4] { { 0, 0, 0, 1, 0, 0 }, { w, 0, 0, 1, 1, 0 }, { 0, h, 0, 1, 0, 1 }, { w, h, 0, 1, 1, 1 }, }; device-DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, v, sizeof(VERTEX)); device-EndScene(); device-Present(nullptr, nullptr, nullptr, nullptr);3.2 码流信息叠加在 D3D 管线里绘制文本“码流信息显示通过 D3D 的文本绘制实现”工程里最常见的做法是用 D3DX 字体在Present之前把文字画到后台缓冲。这样文字和视频在同一条 Present 管线上输出不会出现 GDI 和 D3D 抢占窗口句柄导致的闪烁。需要注意D3DXCreateFont依赖 d3dx9.libVS2019 默认不带 DirectX SDK需要单独引入 DirectX SDK 的 NuGet 包或者旧版 SDK 的 lib 目录。LPD3DXFONT font nullptr; D3DXCreateFont(device, 18, 0, FW_NORMAL, 1, FALSE, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_DONTCARE, _T(Microsoft YaHei), font); device-BeginScene(); device-SetRenderState(D3DRS_ALPHABLENDENABLE, TRUE); device-SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA); device-SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA); // 先画视频再画文字保证文字在最上层 device-SetTexture(0, video_tex); device-DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, v, sizeof(VERTEX)); RECT rc { 12, 8, 480, 80 }; CString info; info.Format(_T(H.264 1920x1080 25fps %d kbps), bitrate / 1000); font-DrawText(nullptr, info, -1, rc, DT_LEFT | DT_TOP | DT_NOCLIP, 0xFF00FFFF); device-EndScene(); device-Present(nullptr, nullptr, nullptr, nullptr);0xFF00FFFF是 ARGB 格式的亮青色前面两个 FF 分别表示透明度和红色分量。文字会盖在视频上因此每帧刷新时不需要清掉后台缓冲D3DSWAPEFFECT_DISCARD会保证上次内容不残留。字体对象和 D3D 纹理一样在Reset之后必须重建否则设备丢失恢复后 DrawText 会直接崩溃。方案适用场景注意点sws_scale 转 RGB32 单纹理1080p 内预览、工控机CPU 增加 5%10%YUV 多纹理 像素着色器4K、多路解码需要 SM3.0 以上显卡D3DXFont 文本叠加码流信息、OSD 显示设备 Reset 后要重建字体4. 截图与 MP4 录像libjpeg 与 Mp4Record 类的核心实现截图功能对应界面上的“截取 JPEG”录像对应“开始/停止 MP4 录制”。这部分的难点不在调用 API而在处理好像素格式转换和时间基换算。解码线程产出的帧是 YUV420PJPEG 需要 RGBMP4 编码器也需要明确的输入格式和时间戳遗漏一步就会得到颜色偏绿或者时长不对的文件。4.1 libjpeg 截图从 AVFrame 到 JPG 的落盘流程截图用的 libjpeg 在工程里以静态库方式编译MFC 按钮回调的执行路径是取当前解码线程最新的 AVFrame如果帧格式不是 RGB先用sws_scale转换再初始化jpeg_compress_struct按 libjpeg 的标准流程逐行写入扫描线。默认保存路径是C:\MYRecPath运行前要确认目录存在。void SaveJpeg(AVFrame* rgb_frame, const char* jpg_path) { struct jpeg_compress_struct cinfo; struct jpeg_error_mgr jerr; cinfo.err jpeg_std_error(jerr); jpeg_create_compress(cinfo); FILE* fp nullptr; fopen_s(fp, jpg_path, wb); // CRT 安全版本 if (!fp) return; jpeg_stdio_dest(cinfo, fp); cinfo.image_width rgb_frame-width; cinfo.image_height rgb_frame-height; cinfo.input_components 3; cinfo.in_color_space JCS_RGB; jpeg_set_defaults(cinfo); jpeg_set_quality(cinfo, 90, TRUE); // 90 是质量TRUE 走优化的哈夫曼表 jpeg_start_compress(cinfo, TRUE); int stride rgb_frame-width * 3; // RGB24 每行字节数 JSAMPROW row[1]; while (cinfo.next_scanline cinfo.image_height) { row[0] rgb_frame-data[0] cinfo.next_scanline * stride; jpeg_write_scanlines(cinfo, row, 1); } jpeg_finish_compress(cinfo); fclose(fp); jpeg_destroy_compress(cinfo); }这段代码隐含两个约定rgb_frame-data[0]是打包的 RGB24因此linesize[0]等于width*3如果从解码器直接拿 YUV420P 往里塞颜色会偏绿偏紫。jpeg_set_quality第二个参数为 TRUE 时编码更慢但体积更小适合后台保存。fopen_s失败时不会返回空指针而是把第二个参数置为 NULL所以要先判断 fp 再继续。C:\MYRecPath这个路径要在程序启动时用CreateDirectory确保存在目录缺失时 fopen 直接失败用户会以为截图按钮没反应。libjpeg 函数作用使用注意jpeg_create_compress初始化压缩对象每次截图必须重新初始化jpeg_stdio_dest绑定输出文件文件写失败时错误回调先触发jpeg_write_scanlines写一行或多行扫描线行指针必须按 Y 方向递增jpeg_finish_compress收尾并刷新缓冲漏调会生成不完整 JPEG4.2 Mp4Record 类容器、H.264 编码器与 PTS 换算Mp4Record 类把 FFmpeg 的 MP4 封装逻辑独立出来我一般会对外暴露StartRecord、InputFrame、StopRecord三个接口。构造时用avformat_alloc_output_context2创建 MP4 容器新增一路视频流并绑定 H.264 编码器。编码器参数需要和源视频匹配但尺寸和帧率可以降级比如源是 4K界面只允许录制 1080p。AVFormatContext* oc nullptr; avformat_alloc_output_context2(oc, nullptr, mp4, mp4_path); AVStream* out_st avformat_new_stream(oc, nullptr); const AVCodec* codec avcodec_find_encoder(AV_CODEC_ID_H264); AVCodecContext* enc avcodec_alloc_context3(codec); enc-width 1920; enc-height 1080; enc-pix_fmt AV_PIX_FMT_YUV420P; enc-time_base AVRational{ 1, 25 }; // 编码时间基 enc-framerate AVRational{ 25, 1 }; enc-bit_rate 2 * 1024 * 1024; // 2Mbps 平均码率 enc-gop_size 50; // I 帧间隔 2 秒 enc-max_b_frames 2; // 引入 B 帧提升压缩率 if (oc-oformat-flags AVFMT_GLOBALHEADER) enc-flags | AV_CODEC_FLAG_GLOBAL_HEADER; avcodec_open2(enc, codec, nullptr); avcodec_parameters_from_context(out_st-codecpar, enc); avio_open(oc-pb, mp4_path, AVIO_FLAG_WRITE); avformat_write_header(oc, nullptr);bit_rate是平均码率2Mbps 适合 1080p 画面变化不大的场景如果录的是快速运动的画面建议调到 46Mbps。gop_size是 I 帧间隔越小越耐拖动、但体积越大。AVFMT_GLOBALHEADER对 MP4 尤其重要没有它某些播放器打开文件时会提示缺少 SPS/PPS。写入单帧时输入 AVFrame 的 PTS 要先做一次时间基换算否则录制出来的文件时长会异常播放器快进定位也不准。// 新版 FFmpeg 的编码写入流程 ret avcodec_send_frame(enc, frame); while (ret 0) { AVPacket* pkt av_packet_alloc(); ret avcodec_receive_packet(enc, pkt); if (ret AVERROR(EAGAIN)) { av_packet_free(pkt); break; } av_packet_rescale_ts(pkt, enc-time_base, out_st-time_base); pkt-stream_index out_st-index; av_interleaved_write_frame(oc, pkt); av_packet_free(pkt); }av_packet_rescale_ts会一次性把 pts、dts、duration 按目标时间基重新缩放比自己用av_rescale_q分别换算更不容易漏字段。av_interleaved_write_frame负责把包按时间顺序交织写入音视频同时录制时必须走它不能用av_write_frame。StopRecord里依次调用av_write_trailer、关闭编码器、释放 IO 上下文。还要注意录制过程中源流分辨率变了MP4 编码器会报错工程上应该在检测到尺寸变化后自动重启录音回路而不是往旧上下文里继续塞帧。5. 在 VS2019 里把依赖配完再解决 D3D 设备丢失和没声音的问题到这里功能链路已经完整最后剩下的是把工程在 VS2019 里正确配置起来。FFmpeg、D3D9、DirectSound、libjpeg 这四部分库全部配齐之后再处理两个最影响体验的问题窗口缩放/锁屏后的 D3D 设备丢失以及 DirectSound 无声。5.1 VS2019 的工程依赖与属性配置工程属性里做三件事C/C 常规附加包含目录指向ffmpeg\include链接器常规附加库目录指向ffmpeg\lib链接器输入里加上avcodec.lib、avformat.lib、avutil.lib、swscale.lib以及d3d9.lib、dsound.lib、d3dx9.lib和 libjpeg 编译出的静态库。建议在预处理定义里加上_CRT_SECURE_NO_WARNINGS避免 libjpeg 源码里的 fopen 告警刷屏。如果命令行里验证 ffmpeg 时提示“不是内部或外部命令”把仓库下的bin目录加进 PATH 即可源码工程运行时直接引用相对路径的 DLL和 PATH 无关。5.2 DirectSound 播放 PCM 的最小回路DirectSound 负责把 FFmpeg 解码出的 PCM 数据送到声卡初始化分三步创建对象、设置协作级别、创建缓冲。LPDIRECTSOUND8 pDS nullptr; DirectSoundCreate8(nullptr, pDS, nullptr); pDS-SetCooperativeLevel(g_hWnd, DSSCL_PRIORITY); DSBUFFERDESC desc { 0 }; desc.dwSize sizeof(desc); desc.dwFlags DSBCAPS_GLOBALFOCUS | DSBCAPS_CTRLPOSITIONNOTIFY; desc.dwBufferBytes 44100 * 2 * 2 / 10; // 约 100ms 缓冲 WAVEFORMATEX wfx { }; wfx.wFormatTag WAVE_FORMAT_PCM; wfx.nChannels 2; wfx.nSamplesPerSec 44100; wfx.wBitsPerSample 16; wfx.nBlockAlign (16 * 2) / 8; wfx.nAvgBytesPerSec 44100 * wfx.nBlockAlign; desc.lpwfxFormat wfx; LPDIRECTSOUNDBUFFER pBuf nullptr; pDS-CreateSoundBuffer(desc, pBuf, nullptr);DSBCAPS_GLOBALFOCUS保证程序不在前台时也能继续出声后台录像时很有用。向缓冲写入数据要带头尾两个指针的Lock处理回绕写满后调Play。FFmpeg 解码出来的音频可能是AV_SAMPLE_FMT_FLTP浮点 planar需要先用swr_convert转成交错 S16否则播放出来是刺耳噪声。严谨做法是让 wfx 跟随解码器输出动态创建音频设备不支持时再做重采样而不是写死 44100 双声道。5.3 两个高频问题的定位路径现象原因处理播放正常界面卡死解码循环跑在 UI 线程拉流解码放辅助线程UI 只收消息全屏或缩放后黑屏D3D 设备丢失TestCooperativeLevel Reset重建纹理画面撕裂没开垂直同步PresentationInterval 改 INTERVAL_ONE截图按钮无反应C:\MYRecPath 目录不存在启动时 CreateDirectory 补建D3D 设备丢失是最容易被忽略的边界条件窗口被最小化、切换分辨率、锁屏再恢复交换链都可能失效。设备丢失后不能直接调 Present要先检查状态。HRESULT hr device-TestCooperativeLevel(); if (hr D3DERR_DEVICELOST) { return; // 等待系统恢复 } else if (hr D3DERR_DEVICENOTRESET) { CleanupTextures(); // 先释放所有纹理和顶点缓冲 device-Reset(present_params); // 参数要和创建时一致 RecreateTextures(); // 重建纹理、字体和顶点 }Reset必须发生在渲染线程且要先把创建的所有纹理、顶点缓冲、D3DXFont 释放干净否则返回D3DERR_INVALIDCALL。重置之后再重建视频和码流信息才能继续显示。预览画面的节奏不建议用Sleep(40)死等用av_usleep(10000)配合主循环里的帧间隔判断可以让 CPU 占用从 15% 降到 8% 以下。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。