☰
D3D YV12视频渲染优化:GPU硬件转换降低CPU占用
2026/10/20 6:53:33 网站建设 项目流程

简介:这份资源面向从事Windows平台视频播放、采集与渲染开发的工程师,提供一套基于Direct3D的YUV视频显示源代码,重点解决YV12等格式在GPU端高效渲染的问题。代码同时兼容YV12、I420、NV12、YUY2、UYVY、RGB24、RGB32、RGB555、RGB565等多种像素格式,并支持半透明文本叠加,曾在Windows XP SP2、DXSDK9.0c与9800GT环境下测试通过。压缩包共72个文件,约1.05MB,以36个h头文件与9个cpp实现文件为核心,辅以dll、lib、vcproj、sln等工程与依赖文件,另有exe、dat、ico等运行与资源文件,结构上按D3DManager、Display、Shader、RenderFactory等模块拆分,便于按需查阅。已有966人学习下载。读者可从中获取完整的D3D渲染管线实现、各格式对应的Shader代码、半透明文字叠加方案以及工程配置参考,适合作为视频渲染模块的移植与二次开发基础。

1. 基于D3D的YV12视频渲染:为什么你的播放器CPU占用率居高不下

如果你做过视频播放器、录屏工具或者远程桌面客户端,大概率遇到过这个场景:解码器已经把 YV12 数据吐出来了,你顺手用 GDI 的StretchDIBits往窗口上一贴,1080p 还能凑合,一到 4K 或者高帧率,CPU 直接飙到 60% 以上,风扇起飞,画面还撕裂。问题不在解码,在渲染路径——YV12 是 YUV 4:2:0 平面格式,GDI 根本不认,你得先转成 RGB,这一步软件转换就是性能黑洞。

基于 D3D 的 YV12 视频渲染,核心思路是让 GPU 直接吃 YUV 数据,在显存里完成 YUV 到 RGB 的转换和缩放,CPU 只负责把解码后的平面数据搬过去。这套方案在 Windows 平台上已经非常成熟,D3D9 的IDirect3DDevice9::CreateOffscreenPlainSurface配合UpdateSurface就能搞定,D3D11 则用 NV12 更顺滑。适合谁?做本地播放器、视频会议客户端、云游戏串流、工业相机预览的开发者,只要你手上有 YUV 数据要上屏,这套路径就值得投入。

2. YV12 与 D3D 纹理格式的对齐:为什么你的 UpdateSurface 总是失败

2.1 YV12 的内存布局与 D3D 的格式映射

YV12 属于 YUV 4:2:0 平面格式,内存里依次排列 Y 平面、V 平面、U 平面。Y 平面宽高等于视频分辨率,V 和 U 平面宽高各是分辨率的一半。注意顺序是 Y-V-U,不是 Y-U-V,这个顺序搞反了画面颜色会直接偏成鬼片。

D3D9 里对应的格式是D3DFMT_YV12,但这里有个血泪经验:不是所有显卡驱动都支持D3DFMT_YV12作为纹理格式。很多集成显卡只支持D3DFMT_NV12,也就是 Y 平面后面交错排列 UV。你创建纹理时如果直接写D3DFMT_YV12,CreateTexture返回D3DERR_INVALIDCALL,这时候别急着骂驱动,先查一下D3DADAPTER_IDENTIFIER9里的能力位。

我一般会先枚举格式支持情况,代码大概长这样:

// 检查 D3D9 对 YV12 和 NV12 的支持情况 bool CheckYUVFormatSupport(IDirect3D9* pD3D, UINT adapter, D3DFORMAT fmt) { D3DDISPLAYMODE mode; pD3D->GetAdapterDisplayMode(adapter, &mode); // CheckDeviceFormat 的 Usage 传 D3DUSAGE_DYNAMIC 表示动态纹理 HRESULT hr = pD3D->CheckDeviceFormat( adapter, D3DDEVTYPE_HAL, mode.Format, D3DUSAGE_DYNAMIC, D3DRTYPE_TEXTURE, fmt ); return SUCCEEDED(hr); }

这段代码的关键参数是D3DUSAGE_DYNAMIC,视频帧每帧都在变,必须用动态纹理。D3DDEVTYPE_HAL表示硬件加速,如果返回失败可以试试D3DDEVTYPE_SW,但软件渲染就没意义了。CheckDeviceFormat的第三个参数是适配器显示格式,一般用桌面当前格式就行。

如果 YV12 不支持,你有两条路:一是让解码器直接输出 NV12,二是自己在 CPU 上做一次 YV12 到 NV12 的转换。后者代价不大,就是把 VU 平面交错成 UV 平面,但能省掉一次全帧 RGB 转换,依然划算。

2.2 创建 YV12 纹理与 UpdateSurface 的正确姿势

创建纹理时,宽高必须和视频分辨率一致,但要注意 D3D9 对纹理尺寸没有强制 2 的幂要求,不过某些老驱动对非 2 幂纹理有性能惩罚。如果你的视频分辨率是 1920x1080,直接创建就行,不用补到 2048。

// 创建 YV12 离屏表面 IDirect3DSurface9* pYV12Surface = nullptr; HRESULT hr = pDevice->CreateOffscreenPlainSurface( width, // 视频宽度 height, // 视频高度 D3DFMT_YV12, // YV12 格式 D3DPOOL_DEFAULT,// 显存池 &pYV12Surface, nullptr ); if (FAILED(hr)) { // 回退到 NV12 或检查驱动能力 }

这里用CreateOffscreenPlainSurface而不是CreateTexture,因为离屏表面可以直接被UpdateSurface更新,不需要锁纹理。D3DPOOL_DEFAULT表示放在显存里,UpdateSurface会走 GPU 拷贝路径,比Lock再Unlock快得多。

更新数据时,YV12 的三个平面要分别拷贝。Y 平面一行是 width 字节,V 和 U 平面一行是 width/2 字节。UpdateSurface的源表面必须是D3DPOOL_SYSTEMMEM,所以你得先创建一个系统内存表面,把解码数据填进去,再调UpdateSurface。

// 系统内存表面,用于暂存解码后的 YV12 数据 IDirect3DSurface9* pSysSurface = nullptr; pDevice->CreateOffscreenPlainSurface( width, height, D3DFMT_YV12, D3DPOOL_SYSTEMMEM, &pSysSurface, nullptr ); // 锁定系统表面,拷贝 Y/V/U 三个平面 D3DLOCKED_RECT lockedRect; pSysSurface->LockRect(&lockedRect, nullptr, D3DLOCK_DISCARD); BYTE* dst = (BYTE*)lockedRect.pBits; // Y 平面 memcpy(dst, yPlane, width * height); // V 平面,注意 pitch 可能大于 width/2 BYTE* vDst = dst + lockedRect.Pitch * height; for (int i = 0; i < height / 2; i++) { memcpy(vDst + i * lockedRect.Pitch, vPlane + i * (width / 2), width / 2); } // U 平面同理 pSysSurface->UnlockRect(); // 拷贝到显存表面 pDevice->UpdateSurface(pSysSurface, nullptr, pYV12Surface, nullptr);

lockedRect.Pitch是系统内存表面的行跨度,可能大于 width,拷贝时不能假设等于 width。D3DLOCK_DISCARD告诉驱动这块内存不需要保留旧内容,驱动可以分配新内存避免等待 GPU,这个标志对动态纹理非常重要,不加的话每帧都会阻塞。

2.3 用 StretchRect 完成 YUV 到 RGB 的硬件转换

数据到了显存里的 YV12 表面,最后一步是画到后台缓冲。D3D9 提供了StretchRect,它可以在不同格式的表面之间做硬件转换和缩放。把 YV12 表面作为源,后台缓冲作为目标,驱动会自动做 YUV 到 RGB 的转换。

// 获取后台缓冲 IDirect3DSurface9* pBackBuffer = nullptr; pDevice->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &pBackBuffer); // 硬件转换并缩放 pDevice->StretchRect( pYV12Surface, // 源:YV12 表面 nullptr, // 源矩形,nullptr 表示整个表面 pBackBuffer, // 目标:后台缓冲 &destRect, // 目标矩形,可以在这里做缩放 D3DTEXF_LINEAR // 线性插值,缩放质量比 POINT 好 );

StretchRect的最后一个参数是过滤方式,D3DTEXF_LINEAR适合缩放场景,D3DTEXF_POINT适合点对点显示。注意StretchRect不支持源和目标都是 YUV 格式,但 YUV 到 RGB 是支持的。如果驱动不支持 YV12 的StretchRect,你会得到D3DERR_INVALIDCALL,这时候只能回退到 shader 方案。

3. 用 D3D11 做 YV12 渲染:NV12 才是亲儿子

3.1 为什么 D3D11 更推荐 NV12 而不是 YV12

D3D11 的纹理格式列表里,DXGI_FORMAT_NV12是明确支持视频渲染的,而 YV12 对应的DXGI_FORMAT_420_OPAQUE在很多场景下不可用。NV12 和 YV12 的区别只在 UV 平面的排列:NV12 是 UV 交错,YV12 是 V 和 U 分开。D3D11 的视频处理器(Video Processor)原生支持 NV12 输入,你只需要把 Y 和 UV 两个平面分别拷贝到纹理的对应位置。

如果你手上只有 YV12 数据,转换到 NV12 的代价很小:分配一块width * height * 3 / 2的缓冲区,Y 平面直接拷贝,UV 平面做一次交错。这个转换在 CPU 上做,1080p 一帧大概 0.5 毫秒,比全帧 RGB 转换快一个数量级。

3.2 创建 NV12 纹理与两个平面的数据拷贝

D3D11 创建 NV12 纹理时,需要指定DXGI_FORMAT_NV12,并且用D3D11_BIND_RENDER_TARGET或D3D11_BIND_SHADER_RESOURCE。视频处理器需要纹理支持D3D11_BIND_RENDER_TARGET。

// 创建 NV12 纹理 D3D11_TEXTURE2D_DESC desc = {}; desc.Width = width; desc.Height = height; desc.MipLevels = 1; desc.ArraySize = 1; desc.Format = DXGI_FORMAT_NV12; desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_DYNAMIC; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; ID3D11Texture2D* pNV12Tex = nullptr; pDevice->CreateTexture2D(&desc, nullptr, &pNV12Tex);

NV12 纹理在 D3D11 里被视为两个平面:平面 0 是 Y,平面 1 是 UV。更新数据时不能直接Map整个纹理,要用D3D11_MAP_WRITE_DISCARD分别映射两个平面。

// 映射 Y 平面 D3D11_MAPPED_SUBRESOURCE mappedY; pContext->Map(pNV12Tex, 0, D3D11_MAP_WRITE_DISCARD, 0, &mappedY); // 拷贝 Y 数据,注意 RowPitch 可能大于 width for (int i = 0; i < height; i++) { memcpy((BYTE*)mappedY.pData + i * mappedY.RowPitch, yPlane + i * width, width); } pContext->Unmap(pNV12Tex, 0); // 映射 UV 平面 D3D11_MAPPED_SUBRESOURCE mappedUV; pContext->Map(pNV12Tex, 1, D3D11_MAP_WRITE_DISCARD, 0, &mappedUV); // UV 平面高度是 height/2,每行 width 字节(U 和 V 交错) for (int i = 0; i < height / 2; i++) { memcpy((BYTE*)mappedUV.pData + i * mappedUV.RowPitch, uvInterleaved + i * width, width); } pContext->Unmap(pNV12Tex, 1);

Map的第二个参数是子资源索引,0 是 Y 平面,1 是 UV 平面。RowPitch是驱动返回的行跨度,可能大于 width,必须按RowPitch来定位每一行。D3D11_MAP_WRITE_DISCARD和 D3D9 的D3DLOCK_DISCARD作用一样,避免 GPU 等待。

3.3 用 VideoProcessor 做硬件 YUV 转换与缩放

D3D11 的视频处理器是一个独立的对象,通过ID3D11VideoDevice创建。它支持输入 NV12、输出 RGB,并且可以在同一次操作里完成缩放。

// 创建视频处理器 D3D11_VIDEO_PROCESSOR_CONTENT_DESC contentDesc = {}; contentDesc.InputFrameFormat = D3D11_VIDEO_FRAME_FORMAT_PROGRESSIVE; contentDesc.InputWidth = width; contentDesc.InputHeight = height; contentDesc.OutputWidth = destWidth; contentDesc.OutputHeight = destHeight; contentDesc.Usage = D3D11_VIDEO_USAGE_PLAYBACK_NORMAL; ID3D11VideoProcessorEnumerator* pEnum = nullptr; pVideoDevice->CreateVideoProcessorEnumerator(&contentDesc, &pEnum); ID3D11VideoProcessor* pProcessor = nullptr; pVideoDevice->CreateVideoProcessor(pEnum, 0, &pProcessor);

创建好处理器后,需要设置输入和输出视图,然后调用VideoProcessorBlt。输入视图绑定 NV12 纹理的平面 0 和平面 1,输出视图绑定后台缓冲的渲染目标。

// 设置输入视图 D3D11_VIDEO_PROCESSOR_INPUT_VIEW_DESC inputViewDesc = {}; inputViewDesc.FourCC = 0; inputViewDesc.ViewDimension = D3D11_VPIV_DIMENSION_TEXTURE2D; inputViewDesc.Texture2D.MipSlice = 0; inputViewDesc.Texture2D.ArraySlice = 0; ID3D11VideoProcessorInputView* pInputView = nullptr; pVideoDevice->CreateVideoProcessorInputView( pNV12Tex, pEnum, &inputViewDesc, &pInputView ); // 设置输出视图 D3D11_VIDEO_PROCESSOR_OUTPUT_VIEW_DESC outputViewDesc = {}; outputViewDesc.ViewDimension = D3D11_VPOV_DIMENSION_TEXTURE2D; outputViewDesc.Texture2D.MipSlice = 0; ID3D11VideoProcessorOutputView* pOutputView = nullptr; pVideoDevice->CreateVideoProcessorOutputView( pBackBufferTex, pEnum, &outputViewDesc, &pOutputView ); // 执行转换 D3D11_VIDEO_PROCESSOR_STREAM stream = {}; stream.Enable = TRUE; stream.OutputIndex = 0; stream.InputFrameOrField = 0; stream.PastFrames = 0; stream.FutureFrames = 0; stream.pInputSurface = pInputView; pVideoContext->VideoProcessorBlt(pProcessor, pOutputView, 0, 1, &stream);

VideoProcessorBlt的最后一个参数是流数组,这里只传一路。pInputSurface指向输入视图,pOutputView是渲染目标。整个过程 GPU 全包,CPU 只参与数据拷贝,4K 60fps 下 CPU 占用可以压到 5% 以内。

4. 避坑指南:YV12 渲染翻车现场合集

4.1 画面颜色偏绿或偏紫

现象:渲染出来的画面整体偏绿,或者人脸发紫。原因:YV12 的 V 和 U 平面顺序搞反了。YV12 是 Y-V-U,NV12 是 Y-UV,如果你把 YV12 数据当 NV12 用,V 和 U 就互换了。解决:检查你的数据源格式,如果是 YV12,在拷贝 UV 平面时先交错 V 再交错 U;如果是 NV12,直接交错 UV。

4.2 UpdateSurface 返回 D3DERR_INVALIDCALL

现象:UpdateSurface调用失败,返回D3DERR_INVALIDCALL。原因:源表面和目标表面的格式不一致,或者源表面不在D3DPOOL_SYSTEMMEM,或者目标表面不在D3DPOOL_DEFAULT。解决:确认源表面是D3DPOOL_SYSTEMMEM,目标表面是D3DPOOL_DEFAULT,两者格式必须相同。另外,UpdateSurface不支持缩放,源和目标尺寸必须一致。

4.3 画面撕裂或闪烁

现象:快速运动场景下画面撕裂,或者窗口 resize 时闪烁。原因:没有等待 GPU 完成上一帧就更新了表面,或者StretchRect和Present之间没有同步。解决:用IDirect3DQuery9做事件查询,在UpdateSurface之前插入查询,确保 GPU 已经读完上一帧。或者用双缓冲表面,交替更新。

4.4 4K 分辨率下纹理创建失败

现象:1080p 正常,4K 下CreateOffscreenPlainSurface返回D3DERR_OUTOFVIDEOMEMORY。原因:显存不足,或者驱动对单块纹理尺寸有限制。解决:检查D3DCAPS9里的MaxTextureWidth和MaxTextureHeight,老显卡可能不支持 4K。如果显存不够,考虑用D3DPOOL_MANAGED让驱动管理内存,但性能会下降。

4.5 D3D11 下 Map 返回 E_INVALIDARG

现象:Map调用失败,返回E_INVALIDARG。原因:纹理创建时没有设置CPUAccessFlags,或者Usage不是D3D11_USAGE_DYNAMIC。解决:创建纹理时Usage设为D3D11_USAGE_DYNAMIC,CPUAccessFlags设为D3D11_CPU_ACCESS_WRITE。另外,NV12 纹理的平面 1 只能通过Map访问,不能直接当 shader resource 用。

5. 进阶技巧:用 Shader 做 YV12 渲染与色彩空间转换

5.1 为什么有时候必须自己写 Shader

StretchRect和VideoProcessorBlt虽然方便,但有些场景下不适用:比如你需要做色彩空间调整(BT.601 到 BT.709)、HDR 色调映射、或者驱动不支持硬件 YUV 转换。这时候就得自己写 pixel shader,把 Y、U、V 三个平面分别采样,手动做矩阵乘法。

5.2 YV12 三平面采样的 Shader 实现

在 D3D11 里,你可以把 NV12 纹理的平面 0 和平面 1 分别创建 shader resource view,然后在 pixel shader 里采样。Y 平面是DXGI_FORMAT_R8_UNORM,UV 平面是DXGI_FORMAT_R8G8_UNORM。

// YV12 到 RGB 的 pixel shader Texture2D<float> texY : register(t0); Texture2D<float2> texUV : register(t1); SamplerState samp : register(s0); float4 main(float2 uv : TEXCOORD0) : SV_Target { float y = texY.Sample(samp, uv).r; float2 uvVal = texUV.Sample(samp, uv).rg; // BT.601 有限范围 YUV 到 RGB y = (y - 16.0 / 255.0) * (255.0 / 219.0); float u = (uvVal.r - 128.0 / 255.0) * (255.0 / 224.0); float v = (uvVal.g - 128.0 / 255.0) * (255.0 / 224.0); float r = y + 1.402 * v; float g = y - 0.344 * u - 0.714 * v; float b = y + 1.772 * u; return float4(r, g, b, 1.0); }

这段 shader 的关键是范围转换:Y 的范围是 16 到 235,UV 的范围是 16 到 240,需要先归一化到 0 到 1。矩阵系数是 BT.601 的标准值,如果你用的是 BT.709,系数要换成对应的值。采样 UV 时,纹理坐标要减半,因为 UV 平面分辨率是 Y 的一半。

5.3 用查询做 GPU 同步的可靠方法

不管用 D3D9 还是 D3D11,多帧渲染时都需要同步。D3D11 里用ID3D11Query的D3D11_QUERY_EVENT类型,在VideoProcessorBlt之后插入查询,下一帧开始前GetData检查是否完成。

// 创建事件查询 D3D11_QUERY_DESC queryDesc = {}; queryDesc.Query = D3D11_QUERY_EVENT; ID3D11Query* pQuery = nullptr; pDevice->CreateQuery(&queryDesc, &pQuery); // 每帧渲染后插入查询 pContext->End(pQuery); // 下一帧开始前检查 BOOL done = FALSE; while (pContext->GetData(pQuery, &done, sizeof(done), 0) == S_OK && !done) { // 等待 GPU 完成,实际项目中可以用 Sleep(0) 让出时间片 }

GetData的最后一个参数是标志位,传 0 表示阻塞等待。如果不想阻塞,传D3D11_ASYNC_GETDATA_DONOTFLUSH,然后轮询。实际项目中我一般用双缓冲加查询,保证 CPU 不会跑在 GPU 前面太多帧,否则显存会堆积。

这套方案我用了好几年,从 D3D9 到 D3D11 再到 D3D12,核心逻辑没变过:让 GPU 干它擅长的事,CPU 只做数据搬运。唯一一次翻车是忘了设置D3DLOCK_DISCARD,结果每帧都在等 GPU,帧率直接腰斩。后来养成习惯,创建动态纹理时第一件事就是检查锁标志。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询