简介:这份资源面向从事Windows平台视频播放、采集与显示开发的C++程序员,聚焦D3D环境下YUV格式视频的GPU渲染问题。代码以像素着色器实现YV12、I420、NV12、YUY2、UYVY及RGB24、RGB32、RGB555、RGB565等多种格式的转换与上屏,并支持半透明文本叠加,可在Windows XP SP2配合DXSDK9.0c与9800GT显卡的环境下运行验证。压缩包共72个文件、约1.05MB,以36个h头文件与9个cpp源文件为主体,按格式拆分为Display、ShaderCode、ps_等模块,另含3个dll、2个lib及sln、vcproj工程文件,附VR_Test测试程序与ReadMe说明,便于直接编译调试。已有967人学习下载。读者可从中获得一套可复用的D3D视频渲染框架,理解各YUV格式的着色器采样与色彩空间转换思路,并借鉴渲染接口封装、日志与字体叠加等工程组织方式,适合作为播放器或视频采集显示项目的参考实现。
1. 从一次播放器黑屏说起:YV12 视频渲染到底难在哪
去年接手一个本地播放器的重构,需求很朴素:把解码器吐出来的 YV12 帧稳定地画到窗口上,1080p 要能跑满 60 帧,CPU 占用别超过 15%。听起来不难,结果第一版用 GDI 的StretchDIBits直接怼,1080p 下 CPU 单核直接飙到 70%,画面还带撕裂。换成 D3D 之后问题变成了另一个:YV12 是 YUV 4:2:0 平面格式,三个平面各自独立,色度平面宽高都是亮度的一半,直接当纹理上传会花屏、偏色、绿边。这个项目就是围绕「YV12 怎么正确、高效地送进 D3D 并交给 Shader 做 YUV→RGB 转换」展开的一套完整实现,涉及 D3D9/D3D11 纹理创建、多平面数据上传、像素着色器采样和色彩空间矩阵。适合正在写播放器、录屏工具、视频会议客户端,或者任何需要把解码帧上屏的开发者。下面按「能干什么 → 怎么跑 → 怎么改」的顺序拆。
2. 渲染管线拆解:从 YV12 内存块到屏幕像素的完整链路
2.1 为什么不能直接把 YV12 当 RGB 纹理用
YV12 的内存布局是三个连续平面:先是Y平面,尺寸width × height;然后是U平面和V平面,各width/2 × height/2。一个 1920×1080 的帧,Y 占 2073600 字节,U 和 V 各占 518400 字节,总共约 3.1MB。如果按 RGB 存同样一帧,是 1920×1080×4 = 8.3MB。这就是 YV12 在视频链路里被广泛使用的原因——带宽省一半以上。
但 D3D 的纹理采样器默认按 RGB 理解数据,你把 Y 平面当纹理传进去,采样出来是灰度图;把 U/V 交错塞进去,颜色就全乱了。正确做法是:把 Y、U、V 分别作为三个纹理(或一个纹理的三个通道)上传,在像素着色器里采样三次,再用 BT.601/BT.709 矩阵换算成 RGB。这个项目走的就是「三纹理 + 像素着色器」路线,兼容 D3D9 和 D3D11 两套后端。
提示:YV12 和 NV12 容易混。NV12 的 U/V 是交错的(UVUVUV…),YV12 是分开的两个平面。上传逻辑完全不同,别拿 NV12 的代码改个名字就用。
2.2 目录结构与模块职责
项目按「解码输出 → 纹理管理 → 着色器 → 呈现」四层组织,目录大致如下:
yv12-d3d-renderer/ ├── src/ │ ├── core/ │ │ ├── frame.h # YV12 帧描述:三平面指针、stride、宽高 │ │ └── color_matrix.h # BT.601/709 矩阵常量与选择逻辑 │ ├── d3d9/ │ │ ├── renderer_d3d9.cpp # D3D9 后端:纹理创建、Upload、Draw │ │ └── shader_yuv.hlsl # YUV→RGB 像素着色器 │ ├── d3d11/ │ │ ├── renderer_d3d11.cpp # D3D11 后端:Texture2D + SRV + Map │ │ └── shader_yuv.hlsl │ ├── platform/ │ │ └── window_win32.cpp # 窗口与消息循环 │ └── main.cpp # 入口:读 YV12 文件循环上屏 ├── assets/ │ └── sample_1920x1080.yv12 # 测试用裸帧序列 └── CMakeLists.txtframe.h里定义的结构是整个项目的契约,解码器输出、文件读取、渲染上传都围绕它转:
// frame.h —— YV12 帧的统一描述,所有后端都吃这个结构 struct YV12Frame { const uint8_t* y = nullptr; // 亮度平面首地址 const uint8_t* u = nullptr; // 色度 U 平面首地址 const uint8_t* v = nullptr; // 色度 V 平面首地址 int width = 0; // 亮度宽,必须是偶数 int height = 0; // 亮度高,必须是偶数 int yStride = 0; // Y 平面每行字节数,可能 > width(对齐) int uvStride = 0; // U/V 平面每行字节数,通常 = yStride/2 int64_t pts = 0; // 显示时间戳,用于音画同步 };这里yStride和uvStride单独给出来,是因为解码器(尤其是硬件解码)输出的行对齐往往不是width,常见是 32 或 64 字节对齐。如果你按width去算偏移,画面会斜切。这是我在项目里踩过的第一个坑,后面避坑章节细说。
2.3 D3D11 后端:纹理创建与三平面上传
D3D11 下用DXGI_FORMAT_R8_UNORM创建三个单通道纹理,分别对应 Y、U、V。上传时用Map+memcpy逐行拷贝,因为 stride 可能不等于纹理宽度,不能一次性整块拷。
// renderer_d3d11.cpp —— 创建三个 R8 纹理 bool RendererD3D11::CreateTextures(int w, int h) { D3D11_TEXTURE2D_DESC desc = {}; desc.Format = DXGI_FORMAT_R8_UNORM; // 单通道 8bit,正好装一个平面 desc.Width = w; desc.Height = h; desc.MipLevels = 1; desc.ArraySize = 1; desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_DYNAMIC; // 每帧更新,用 DYNAMIC desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; if (FAILED(device_->CreateTexture2D(&desc, nullptr, &texY_))) return false; // U/V 平面宽高减半 desc.Width = w / 2; desc.Height = h / 2; if (FAILED(device_->CreateTexture2D(&desc, nullptr, &texU_))) return false; if (FAILED(device_->CreateTexture2D(&desc, nullptr, &texV_))) return false; return true; }D3D11_USAGE_DYNAMIC配合CPU_ACCESS_WRITE是每帧更新纹理的标准组合,代价是 GPU 不能直接读这块内存做其他用途,但对纯上屏场景足够。如果要做多路合成,可以改成DEFAULT+ staging 纹理,用CopyResource上传,性能更好但代码多一层。
上传函数的关键是逐行拷贝,处理 stride 差异:
// 上传单个平面:srcStride 是源数据行字节数,dstW 是纹理宽度 void RendererD3D11::UploadPlane(ID3D11Texture2D* tex, const uint8_t* src, int srcStride, int dstW, int dstH) { D3D11_MAPPED_SUBRESOURCE mapped; if (FAILED(ctx_->Map(tex, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped))) return; // mapped.RowPitch 是 GPU 侧行对齐后的字节数,可能大于 dstW for (int y = 0; y < dstH; ++y) { memcpy((uint8_t*)mapped.pData + y * mapped.RowPitch, src + y * srcStride, dstW); // 只拷有效宽度,不拷对齐填充 } ctx_->Unmap(tex, 0); }D3D11_MAP_WRITE_DISCARD告诉驱动「旧内容不要了」,驱动可以换一块 buffer 给你写,避免等待 GPU 读完上一帧,这是每帧更新纹理时最常用的 flag。mapped.RowPitch是驱动返回的实际行跨度,通常比dstW大(对齐到 256 字节),所以必须按RowPitch定位每行起点,只拷dstW个有效字节。
2.4 像素着色器:YUV→RGB 矩阵与采样
三个纹理绑定到像素着色器后,采样得到y、u、v三个 0~1 的浮点值。注意 Y 是 full range 还是 limited range(16~235),U/V 是 0~255 还是 -128~127 偏移,这决定了要不要减偏移、乘系数。项目里按 limited range 处理,这是绝大多数解码器的默认输出。
// shader_yuv.hlsl —— BT.601 limited range 转换 Texture2D texY : register(t0); Texture2D texU : register(t1); Texture2D texV : register(t2); SamplerState samp : register(s0); float4 main(float2 uv : TEXCOORD0) : SV_Target { float y = texY.Sample(samp, uv).r; float u = texU.Sample(samp, uv).r - 0.5; // 0~1 映射回 -0.5~0.5 float v = texV.Sample(samp, uv).r - 0.5; // limited range:Y 从 [16,235] 拉伸到 [0,1] y = (y - 16.0 / 255.0) * (255.0 / 219.0); float r = y + 1.402 * v; float g = y - 0.344 * u - 0.714 * v; float b = y + 1.772 * u; return float4(saturate(float3(r, g, b)), 1.0); }采样 U/V 时用的uv坐标和 Y 相同,但 U/V 纹理尺寸只有一半,硬件采样器会自动按纹理尺寸归一化,所以不需要手动把坐标乘 0.5——这是很多人第一次写会搞错的地方,手动乘了反而采样到错误位置。矩阵系数 1.402 / 0.344 / 0.714 / 1.772 是 BT.601 的标准值;如果源是 BT.709(高清常见),换成 1.5748 / 0.1873 / 0.4681 / 1.8556。项目里color_matrix.h根据帧的元数据或分辨率阈值自动选。
2.5 D3D9 后端的差异点
D3D9 没有R8_UNORM这种单通道纹理格式的完整支持,常见做法是用D3DFMT_L8(亮度 8bit)装 Y 平面,U/V 平面则用D3DFMT_A8L8把两个平面塞进一个纹理的 L 和 A 通道,减少一次采样。这是 D3D9 时代的经典优化,代码里renderer_d3d9.cpp的UploadUV就是干这个的:
// D3D9:把 U、V 交错写入 A8L8 纹理,L=U,A=V void RendererD3D9::UploadUV(const YV12Frame& f) { D3DLOCKED_RECT lr; texUV_->LockRect(0, &lr, nullptr, D3DLOCK_DISCARD); for (int y = 0; y < f.height / 2; ++y) { uint8_t* dst = (uint8_t*)lr.pBits + y * lr.Pitch; const uint8_t* su = f.u + y * f.uvStride; const uint8_t* sv = f.v + y * f.uvStride; for (int x = 0; x < f.width / 2; ++x) { dst[x * 2 + 0] = su[x]; // L 通道 = U dst[x * 2 + 1] = sv[x]; // A 通道 = V } } texUV_->UnlockRect(0); }这样像素着色器只需两次采样(Y 一次、UV 一次),比三次采样省一点。D3D9 的LockRect用D3DLOCK_DISCARD同样是为了避免等待 GPU。如果你的目标平台只有 D3D11,这段可以跳过,但维护老播放器时这套代码很常见。
3. 跑起来:编译、测试帧准备与首帧验证
3.1 环境与依赖
项目用 CMake 组织,Windows 平台,依赖只有系统自带的 D3D9/D3D11 和 Windows SDK,没有第三方库。编译器用 VS2019 及以上,CMake 3.15+。D3D9 后端需要链接d3d9.lib,D3D11 后端链接d3d11.lib、dxgi.lib,着色器编译用d3dcompiler.lib(运行时编译 HLSL,方便改 shader 不用重编)。
# 在项目根目录 mkdir build && cd build cmake .. -G "Visual Studio 16 2019" -A x64 cmake --build . --config Release编译产物是yv12_renderer.exe,默认走 D3D11 后端,命令行参数--d3d9切到 D3D9。如果 CMake 报找不到 Windows SDK,检查 VS 安装时有没有勾「Windows 10 SDK」,这是最常见的环境问题。
3.2 准备测试帧
assets/sample_1920x1080.yv12是裸帧序列,没有文件头,程序按固定宽高循环读取。如果你手头没有 YV12 文件,用 ffmpeg 从任意视频转一份:
# 转成 1920x1080 的 YV12 裸流,取前 120 帧 ffmpeg -i input.mp4 -pix_fmt yuv420p -s 1920x1080 -frames:v 120 -f rawvideo sample.yv12-pix_fmt yuv420p就是 YV12 的 ffmpeg 叫法,-f rawvideo去掉容器只留裸数据。生成的每帧大小固定为1920*1080*3/2 = 3110400字节,程序用文件大小除以帧大小算总帧数。如果播放时画面上下颠倒,说明源是 bottom-up 存储,在UploadPlane里把行序反过来即可。
3.3 首帧验证清单
跑起来后先别急着看流畅度,按这个顺序验证,能快速定位问题出在哪一层:
| 现象 | 大概率原因 | 排查位置 |
|---|---|---|
| 全黑 | 纹理没绑上 / shader 编译失败 | 看d3dcompiler返回的 error blob |
| 灰度正常但无色 | U/V 纹理没采样到 | 检查 SRV 绑定槽位 t1/t2 |
| 整体偏绿 | U/V 偏移没减 0.5 | shader 里- 0.5是否漏了 |
| 画面斜切 | stride 用了 width 而非实际 stride | UploadPlane的 srcStride 参数 |
| 颜色对但发灰 | limited/full range 判断错 | color_matrix.h的 range 标志 |
先确认灰度图正确,再确认颜色,最后才调性能。这个顺序能避免在颜色不对的时候去怀疑性能问题,白折腾。
4. 避坑与排查:五个真实翻车记录
4.1 现象:1080p 画面右侧有一条斜的重复条带
原因:解码器输出的yStride是 1920 对齐到 2048,代码里按width=1920算行偏移,导致每行错位 128 字节,累积成斜条。解决:所有平面偏移一律用stride计算,width只用于决定拷贝多少有效字节。改完后在UploadPlane里加了一句断言srcStride >= dstW,防止以后再传错。
4.2 现象:D3D11 下每帧 CPU 占用随分辨率线性上涨,4K 时单核 40%
原因:用了D3D11_USAGE_DEFAULT+UpdateSubresource上传,驱动内部做了一次额外拷贝,且UpdateSubresource不能和 GPU 并行。解决:改成DYNAMIC+Map(WRITE_DISCARD),CPU 直接写驱动给的 buffer,省掉中间拷贝。4K 下 CPU 降到 12% 左右。注意DYNAMIC纹理不能同时是D3D11_BIND_RENDER_TARGET,如果要做后期处理得另建一张。
4.3 现象:D3D9 后端在某些核显上首帧花屏,之后正常
原因:LockRect用了D3DLOCK_DISCARD,但纹理创建时Pool设成了D3DPOOL_MANAGED,MANAGED 池不支持 DISCARD,驱动行为未定义。解决:改成D3DPOOL_DEFAULT,并自己管理纹理重建(设备丢失时Reset后重新创建)。设备丢失在 D3D9 里是常态,D3DERR_DEVICELOST必须处理,否则切换分辨率或锁屏回来就黑屏。
4.4 现象:颜色整体偏红,换一个片源又正常
原因:片源是 BT.709 编码,代码固定用 BT.601 矩阵。解决:在YV12Frame里加ColorSpace字段,由解码器或容器元数据填充,color_matrix.h按字段选矩阵。没有元数据时按分辨率兜底:宽度 ≥ 1280 用 709,否则 601。这个兜底规则不是绝对正确,但覆盖了绝大多数情况。
4.5 现象:窗口拉伸后画面模糊,且边缘有黑边
原因:采样器用了默认的线性过滤,且纹理坐标没处理D3D11_FILTER的 mip 问题;黑边是因为 viewport 没跟着窗口尺寸更新。解决:采样器显式设MIN_MAG_MIP_LINEAR,RSSetViewports在WM_SIZE时重设。如果要做像素级清晰(比如录屏预览),把过滤改成MIN_MAG_MIP_POINT,但缩放时会锯齿,按场景选。
5. 二次开发:把三纹理方案改成单纹理 NV12 兼容与性能验证
这套代码最值得改的地方是纹理组织方式。三纹理方案清晰但采样次数多,如果后续要接硬件解码器(很多直接吐 NV12),可以加一条 NV12 路径:Y 一个R8纹理,UV 一个R8G8纹理,shader 里 UV 只采样一次。改动集中在三处:CreateTextures多一个分支、UploadUV改成交错拷贝、shader 里texUV.Sample().rg一次取两个分量。我一般会保留两套 shader,用#define切换,避免运行时分支。
验证改动是否有效,别只看任务管理器。用 D3D11 的ID3D11Query打时间戳,测「Map 开始到 Present 返回」的 CPU 耗时,以及 GPU 端的D3D11_QUERY_TIMESTAMP_DISJOINT。我习惯在Present前后各插一个 query,跑 300 帧取中位数,比平均值抗抖动。下面是一个最小测量骨架:
// 每帧开始 ctx_->Begin(queryDisjoint_); ctx_->End(queryTimestampBegin_); // ... 渲染 ... ctx_->End(queryTimestampEnd_); swapChain_->Present(1, 0); // 开 vsync,测的是稳定帧率下的耗时 // 下一帧读取(query 有延迟,不能当帧读) UINT64 t0, t1; D3D11_QUERY_DATA_TIMESTAMP_DISJOINT dj; if (ctx_->GetData(queryTimestampBegin_, &t0, sizeof(t0), 0) == S_OK && ctx_->GetData(queryTimestampEnd_, &t1, sizeof(t1), 0) == S_OK && ctx_->GetData(queryDisjoint_, &dj, sizeof(dj), 0) == S_OK && !dj.Disjoint) { double ms = double(t1 - t0) / double(dj.Frequency) * 1000.0; }Disjoint为真说明测量期间发生了降频或切换,这一帧数据要丢弃,否则会得到离谱的低值。这个细节很多人漏掉,测出来的数字自己都不敢信。
还有一个改造方向是零拷贝:如果解码器和渲染在同一个 D3D11 设备上,直接把解码输出的纹理当 SRV 用,跳过Map/memcpy。前提是解码器支持输出DXGI_FORMAT_NV12纹理,且设备能共享。这条路能把 4K 的 CPU 占用压到 5% 以下,但设备共享和格式协商的代码量不小,建议先把三纹理路径跑稳再动。
从那以后我每次接新的解码器输出,都强制先打一张灰度图确认 stride 和平面顺序,再开颜色——这个习惯帮我省掉了至少三次「颜色不对查半天结果发现是 stride」的返工。希望帮到你。
本文还有配套的精品资源,点击获取