简介一套基于Direct3D的YUV视频显示源代码面向Windows平台下的图形与视频开发者。工程以YV12为核心同时兼容I420、NV12、YUY2、UYVY以及多种RGB格式可借助GPU Shader完成色彩空间转换与渲染并支持半透明文本叠加。作者在Windows XP SP2、DXSDK9.0c及9800GT环境下测试通过适合研究D3D渲染管线或需要实现视频播放预览功能的开发者参考。压缩包共72个文件约1.05MB其中36个头文件与9个C源文件构成主要实现另有DLL动态库、Shader代码、Visual Studio工程和一份可运行的测试程序便于对照调试。当前已有965人学习工程目录包含D3DManager、Display系列、Shader系列及VR_Test等模块可快速定位YV12/I420/NV12各显示链路。通过该源码可理解YUV数据如何上传为纹理、编写像素着色器完成格式转换以及复用GPU实现高效视频渲染与叠加具有不错的工程参考价值。1. 为什么视频渲染绕不开D3D一帧YV12数据上屏的最后一公里解码库丢给你一帧 YV12宽 1920 高 1080你第一反应可能是把它拷成系统位图再画到窗口上。跑一次就会发现CPU 占用直线上升拖动窗口卡顿全屏缩放后颜色还发糊。YV12 是软解最常见的输出格式之一它把亮度与色度拆成三个平面让 CPU 逐像素转换后再上屏等于把本该 GPU 干的活全揽到自己身上。基于 D3D 的 YV12 视频渲染解决的就是这个事用 Direct3D 接管缩放、色彩空间转换和上屏把 CPU 腾出来继续做解码和图像分析。D3D 在这里不只是“显示接口”它的纹理采样和着色器管线天然适合做 YUV 到 RGB 的批量转换这也是播放器、预览工具、视频分析界面普遍采用的落地路线。下面内容面向两类读者一类是要在播放器或 SDK 里实现稳定渲染的开发者另一类是临时给分析工具加视频预览的人。我会把 D3D9 与 D3D11 两条实现路径、着色器矩阵参数和最常见的翻车点一次说清你可以照着代码段直接抄进自己的工程。2. YV12内存布局与渲染方案选型先认清这份数据长什么样2.1 YV12的平面结构Y、V、U三段顺序不能错YV12 属于 planar 420 格式。第一块是宽乘高的亮度平面 Y之后是一个宽高各一半的 V 平面最后是一个宽高各一半的 U 平面。注意顺序是 Y、V、U所以叫 YV12如果把 V 和 U 两个平面交换位置就成了 I420。这两个格式只是色度平面顺序不同渲染时绑定纹理的顺序反了画面就会发绿或发紫。实际的解码输出并不总是和画面尺寸严格相等。编码为 YV12 的视频宽度和高度必须是偶数因为 V、U 平面要做 2:1 抽样很多解码器为了和硬解表面兼容还会把每行字节数对齐到 32 或 64。例如宽度 1920 的画面某开源软解库输出时行字节可能是 1920也可能是 1984。如果你假设 pitch 等于 width直接整块拷贝画面就会出现斜切或色度错位。YV12 的内存布局可以归纳为三个连续区域分量位置尺寸字节数Y数据起始W × HW × HVY 之后(W/2) × (H/2)W × H / 4UV 之后(W/2) × (H/2)W × H / 4解码器通常给到的是三个独立指针分别指向这三个平面的首地址也有少数接口给一个连续大缓冲区此时需要通过偏移量计算平面起始位置。无论哪种形式你都要先把每行实际的字节间距拿到手这个间距在不同解码器实现里差距很大是后续所有拷贝操作的基础。2.2 三种上屏路线CPU转换、D3D9三纹理、D3D11纹理路线一是 CPU 做 YUV 到 RGB 转换然后走系统 GDI 画上去。这个方法实现量最小但一帧 1080p 需要几百万次浮点乘法还要把结果位图再拷贝到窗口CPU 占用高拉伸时还会出现明显的锯齿和色边。它适合在调试阶段临时打点不适合放进产品。路线二是 D3D9 三纹理加像素着色器。把 Y、V、U 分别放入三个单通道纹理在着色器里做色彩转换。D3D9 对老系统的覆盖最好ps_2_0 级别的着色器几乎在所有显卡上都能跑代码结构简单性能也能跑满。缺点是 API 老多线程能力弱但做单路视频显示绰绰有余。路线三是 D3D11。它更适合现在的多核环境还能顺带接入 DXVA 硬解链但 DXGI 格式列表里没有直接的 YV12需要先重排成 NV12或者继续用三个 R8 纹理。三种路线对比如下路线CPU 负载画面质量系统兼容实现成本CPU 转 RGB GDI高一般最广最低D3D9 三纹理 PS低好老到新均可中D3D11 三纹理或 NV12低好较新系统中偏高2.3 双轨选型D3D9保底D3D11做默认我自己的选择习惯是保留两条路径。D3D9 路径放在底层作为老环境回退方案D3D11 路径作为默认渲染代码结构更清晰后续如果要迁移到 D3D12D3D11 的着色器和资源管理思路可以直接平移。如果只是给内部工具加个视频预览直接上 D3D11 就行不需要兼容老系统。如果做的是播放器内核或对外发布的分析软件建议两条路径都保留用运行时的系统版本检测决定走哪条。D3D9 不值得为它刻意优化到极致但值得保留一份能跑的代码做兜底。3. 基于D3D9的YV12渲染三段式纹理与ps_2.0着色器转换3.1 为什么不用 D3DFMT_YV12而是拆成三个 L8 纹理D3D9 其实定义过 D3DFMT_YV12 这个格式但实际使用中很多老驱动会把它当作 NV12 处理U、V 平面的读取方式和你预期不一致同一份代码在不同显卡上颜色表现不同。为了避免这种驱动层面的玄学问题工程上更可靠的做法是拆成三个独立的 L8 单通道纹理每个纹理只存一个分量。选择 D3DFMT_L8 而不是 D3DFMT_A8R8G8B8是因为每个分量只有 8 位用单通道纹理最省显存而且 L8 在像素着色器里通过.r通道就能取到亮度值不需要额外做通道掩码。有些实现会用 A8也能跑但采样时读的是.a通道容易和别的代码混用出问题。创建纹理时注意使用动态纹理池接收帧时能减少 GPU 同步等待。// 按向上对齐 16 字节计算纹理宽度避免解码器行对齐导致错位 int strideY (width 15) ~15; int strideUV (strideY / 2 15) ~15; IDirect3DTexture9* texY nullptr; IDirect3DTexture9* texV nullptr; IDirect3DTexture9* texU nullptr; // 只在初始化阶段创建一次不要在接收帧的回调里反复创建 g_pD3D9Device-CreateTexture(strideY, height, 1, D3DUSAGE_DYNAMIC, D3DFMT_L8, D3DPOOL_DEFAULT, texY, nullptr); g_pD3D9Device-CreateTexture(strideUV, height / 2, 1, D3DUSAGE_DYNAMIC, D3DFMT_L8, D3DPOOL_DEFAULT, texV, nullptr); g_pD3D9Device-CreateTexture(strideUV, height / 2, 1, D3DUSAGE_DYNAMIC, D3DFMT_L8, D3DPOOL_DEFAULT, texU, nullptr);这里有两个关键参数。D3DUSAGE_DYNAMIC表示这块纹理会被频繁更新GPU 会为它安排更适合写入的显存区域D3DPOOL_DEFAULT配合动态用法纹理最终存在于显存中每帧更新走的是驱动内部的更新通道而不是系统内存到显存的慢速拷贝。纹理宽度用strideY而不是width是为了让纹理每一行的内存布局和解码器的行布局一致。D3D9 创建纹理后会有自己的 Pitch这个 Pitch 和strideY不一定相等所以真正拷贝时仍要逐行处理。3.2 把 YV12 帧拆进三个纹理逐行拷贝是唯一稳妥方式每来一帧要做三次上传Y 平面一次V 平面一次U 平面一次。常见误区是把三个平面当成一个连续内存整体用一次memcpy拷进一个纹理这在源数据 stride 和纹理 Pitch 一致时碰巧能工作一旦遇到 1984 那种对齐值画面瞬间错位。正确做法是 LockRect 加逐行拷贝。D3DLOCK_DISCARD告诉驱动整块纹理内容不需要保留驱动可以跳过旧数据回读性能明显好于默认的锁定方式。D3DLOCKED_RECT lock; texY-LockRect(0, lock, nullptr, D3DLOCK_DISCARD); const BYTE* srcY YV12Frame-pY; // 解码输出的 Y 平面 for (int y 0; y height; y) { memcpy((BYTE*)lock.pBits y * lock.Pitch, srcY y * strideY, width); } texY-UnlockRect(0); texV-LockRect(0, lock, nullptr, D3DLOCK_DISCARD); const BYTE* srcV YV12Frame-pV; // V 平面分辨率减半 for (int y 0; y height / 2; y) { memcpy((BYTE*)lock.pBits y * lock.Pitch, srcV y * strideY / 2, width / 2); } texV-UnlockRect(0); // texU 的更新代码与 texV 完全一致只是源指针换成 pU这里特别要注意 V 和 U 平面的行字节数。很多解码器会把 Y 平面的 stride 对齐到 16那么 UV 平面的行字节就是strideY / 2因为宽度减半后仍然保持对齐。如果你单独计算strideUV也要保证它和源数据一致。拷贝完成后纹理里就是一张可供 GPU 采样的三段式亮度色度图占用的显存是原来的 1.5 倍也就是一个 1080p 帧大约 3MB完全可以接受。3.3 像素着色器做 YUV 到 RGB矩阵与零点偏移D3D9 的像素着色器把每个纹理采样值归一化到 0 到 1。Y 平面黑电平是 16白电平是 235归一化后大约在 0.0627 到 0.9216 之间U、V 的零点在 128归一化后是 0.5。所以着色器第一步要做的是把 U、V 各自减 0.5把零点移到中间否则颜色会整体偏移。下面这段 HLSL 编译到 ps_2_0 就能跑兼容性最好。用后面的 saturate 对结果做截断避免负值显示成黑色。// PS 2.0使用三个纹理采样器 sampler2D texY : register(s0); sampler2D texU : register(s1); sampler2D texV : register(s2); float4 PS(float2 uv : TEXCOORD0) : COLOR0 { float y tex2D(texY, uv).r; float u tex2D(texU, uv).r - 0.5f; // U 零点在 0.5 float v tex2D(texV, uv).r - 0.5f; // V 零点在 0.5 // BT.601 有限范围近似展开 float r y 1.402f * v; float g y - 0.344f * u - 0.714f * v; float b y 1.772f * u; return float4(saturate(r), saturate(g), saturate(b), 1.0f); }矩阵系数来源是 BT.601 标准在有限范围下的近似解。1.402对应 R 通道的 V 增益1.772对应 B 通道的 U 增益G 通道同时受 U 和 V 影响所以有两个负系数。这里没有做 full range 的拉伸如果源数据本身是 full range整体画面对比度会偏低后面进阶部分会讲怎么切换。3.4 渲染一个全屏四边形三个纹理如何绑定纹理准备好后画一个覆盖窗口的四边形即可。顶点包含位置和纹理坐标纹理坐标范围是 0 到 1Y、U、V 三个纹理共用同一套坐标。struct VERTEX { float x, y, z, tu, tv; }; VERTEX quad[] { { -1.0f, -1.0f, 0.0f, 0.0f, 1.0f }, { 1.0f, -1.0f, 0.0f, 1.0f, 1.0f }, { -1.0f, 1.0f, 0.0f, 0.0f, 0.0f }, { 1.0f, 1.0f, 0.0f, 1.0f, 0.0f }, }; g_pD3D9Device-SetFVF(D3DFVF_XYZ | D3DFVF_TEX1); g_pD3D9Device-SetTexture(0, texY); g_pD3D9Device-SetTexture(1, texU); g_pD3D9Device-SetTexture(2, texV); g_pD3D9Device-DrawPrimitiveUP(D3DPT_TRIANGLESTRIP, 2, quad, sizeof(VERTEX));上面的纹理坐标 T 方向有一处细节当tv为 0 时采样的是纹理第一行。D3D9 的纹理坐标系原点在左上而窗口坐标原点也在左上如果解码器输出的 YV12 第一行对应画面顶部这个坐标直接用即可但如果解码器按底部优先输出就得把tv取反这个问题在避坑章节展开。4. 基于D3D11的YV12渲染NV12重排与R8三纹理方案4.1 DXGI不认YV12把它重排成 NV12 只差一步DXGI 格式列表里没有 YV12只有 NV12 和 YUY2。NV12 和 YV12 都是 420 planarY 平面完全一样区别在色度NV12 把 U、V 交错存放在同一个平面YV12 的 V 和 U 是两个独立平面。所以要接入 DXGI 原生的 NV12 纹理只做一件事把源数据里的 V、U 两个平面交错写入 NV12 的 UV 区域U 在前V 在后每两个 Y 像素共享一对 UV。// srcV 和 srcU 是 YV12 的两个色度平面 // dstData 是 NV12 纹理 Map 返回的地址rowPitch 是此时的行字节数 BYTE* pY (BYTE*)dstData; // NV12 的 UV 平面紧随 Y 平面之后 BYTE* pUV pY (size_t)rowPitch * height; for (int y 0; y height / 2; y) { const BYTE* rowU srcU (size_t)y * uvPitch; const BYTE* rowV srcV (size_t)y * uvPitch; BYTE* outRow pUV (size_t)y * rowPitch; for (int x 0; x width / 2; x) { outRow[x * 2 0] rowU[x]; // U 在前 outRow[x * 2 1] rowV[x]; // V 在后 } }这段循环是纯内存操作1080p 下一帧大约 400 万次字节写入耗时很低。如果你的项目只是做渲染不接硬解链我更推荐继续用三纹理方案省掉这次重排也省掉处理双平面 SRV 的复杂度。4.2 用三个 R8 纹理绕开格式限制D3D11 里也可以照搬 D3D9 的思路创建三个 DXGI_FORMAT_R8_UNORM 纹理。每个 R8 纹理就是一个 8 位单通道纹理采样起来和 L8 完全类似而且不需要任何驱动扩展所有支持 D3D11 的显卡都认这个格式。D3D11_TEXTURE2D_DESC descY {}; descY.Width width; descY.Height height; descY.MipLevels 1; descY.ArraySize 1; descY.Format DXGI_FORMAT_R8_UNORM; descY.SampleDesc.Count 1; descY.SampleDesc.Quality 0; descY.Usage D3D11_USAGE_DYNAMIC; descY.BindFlags D3D11_BIND_SHADER_RESOURCE; descY.CPUAccessFlags D3D11_CPU_ACCESS_WRITE; descY.MiscFlags 0; ID3D11Texture2D* texY nullptr; g_pd3dDevice-CreateTexture2D(descY, nullptr, texY); // texV 和 texU 的 desc 相同只改宽高Width width / 2Height height / 2参数里有几个关键点Usage 必须是 DYNAMIC否则 CPU 无法每帧 Map 写入BindFlags 标注 SHADER_RESOURCE着色器才能采样CPUAccessFlags 配 WRITE这和 DYNAMIC 是一对缺一个 Map 都会失败。每帧更新时Map 三个纹理逐行拷贝和 D3D9 的逻辑一模一样D3D11_MAPPED_SUBRESOURCE map; g_pd3dContext-Map(texY, 0, D3D11_MAP_WRITE_DISCARD, 0, map); const BYTE* srcY YV12Frame-pY; for (int y 0; y height; y) { memcpy((BYTE*)map.pData y * map.RowPitch, srcY y * strideY, width); } g_pd3dContext-Unmap(texY, 0);4.3 D3D11 的着色器与绑定差异HLSL 部分比 D3D9 干净许多不需要 sampler2D 全局对象直接用 Texture2D 加 SamplerState。Texture2Dfloat texY : register(t0); Texture2Dfloat texU : register(t1); Texture2Dfloat texV : register(t2); SamplerState samp : register(s0); float4 PS(float2 uv : TEXCOORD0) : SV_Target { float y texY.Sample(samp, uv).r; float u texU.Sample(samp, uv).r - 0.5f; float v texV.Sample(samp, uv).r - 0.5f; float r y 1.402f * v; float g y - 0.344f * u - 0.714f * v; float b y 1.772f * u; return float4(saturate(r), saturate(g), saturate(b), 1.0f); }U、V 纹理尺寸是 width/2 和 height/2但采样时仍然用和 Y 相同的纹理坐标。硬件采样器会自动做双线性插值把半分辨率的色度拉伸到亮度分辨率形成平滑的色彩过渡。绑定思路与 D3D9 不同D3D11 里级联到像素着色器需要 PSGetShaderResources 和 PSSetShaderResources分别绑到 t0、t1、t2。采样器在 PSSetSamplers 里绑到 s0。如果你漏了采样器绑定画面会直接是黑色这是新手常见问题。注意这里没有提到结束语。5. YV12渲染避坑指南颜色发绿、画面颠倒与纹理错位5.1 画面整体发绿或发紫U 和 V 顺序搞反了现象整帧图像的肤色发绿暗部发紫像是色彩通道被交换。原因YV12 的顺序是 Y、V、U而 I420 是 Y、U、V。很多人在拷贝或绑定时想当然地按 Y、U、V 处理导致 U 平面进了 V 纹理V 平面进了 U 纹理B 和 R 通道整体交换。解决把 U、V 两个纹理的绑定或拷贝源对调。调试时先在源数据里打印前几个字节拿一帧纯红画面V 平面数值应该整体偏高U 平面整体偏低。这个检查比看画面猜颜色快得多。5.2 画面上下颠倒纹理坐标与源数据行序不一致现象视频显示上下翻转字幕从底部跑到顶部。原因YV12 源数据通常第一字节对应画面顶部但部分采集卡或旧解码库按位图顺序输出第一行反而是画面底部。即使源数据正常你手动翻转顶点纹理坐标时也可能把 Y 和 UV 的翻转方向搞反。解决先确认源数据第一行对应视觉顶部还是底部。如果源正常但显示颠倒把顶点里的 tv 改成 1 - tv如果源是底部优先拷贝时反向逐行写。注意 UV 平面的翻转要和 Y 平面保持一致否则会出现亮度正常、色度错位一格的怪异现象。5.3 画面斜切或色度错位Pitch 没对上现象亮度画面斜向撕裂或者画面整体看起来像平行四边形。原因解码器输出的每行字节数不等于画面宽度。比如 1920 宽的 Y 平面实际行字节是 1984。一次性把整块拷进纹理每行末尾多出的字节会把下一行推偏越靠下的行偏移越大。解决所有平面都逐行拷贝循环里用源 stride 取数据用纹理的 Pitch 写目标。不要把 width 当成 stride 用。拷完后可以数一下最后一行末尾是否有杂色杂色说明还有对齐差一点检查map.RowPitch和源strideY是否相等。5.4 撕裂或 CPU 占满Present 参数与动态纹理用法不对现象画面出现横向撕裂或帧率掉到个位数CPU 占用飙高。原因D3D9 的 Present 间隔没有设置默认立即返回显示器和渲染不同步或者 LockRect 时没有用 DISCARD驱动需要保留旧内容GPU 频繁等待 CPU。解决D3D9 创建交换链时把 Present 间隔设为 INTERVAL_1等同于开启垂直同步D3DPRESENT_PARAMETERS pp {}; pp.Windowed TRUE; pp.SwapEffect D3DSWAPEFFECT_DISCARD; pp.BackBufferCount 1; pp.PresentationInterval D3DPRESENT_INTERVAL_1;D3D11 对应的做法是交换链创建后Present 的第一个参数写 1。自定义内存中的数据显示Present Interval 也能观感显著改善。动态纹理的 Lock 或 Map 一定要带 DISCARD没有这个标志会触发 GPU 回读旧内容延迟立刻上来了。5.5 性能翻车每帧重新创建纹理现象整个渲染循环只有 20 帧显存占用持续上涨。原因在接收帧的回调里调用 CreateTexture 再在下一帧释放等于每帧都重画一份显存。驱动创建纹理时可能触发同步GPU 排队等待 CPU 释放旧资源越跑越卡。解决纹理只在初始化时创建一次运行期间只做 LockRect 或 Map 加拷贝。如果画面尺寸变了先释放旧纹理再创建但把检查条件放在接收到尺寸变化事件的位置而不是每帧都判断。这个改动通常能把帧率从个位数拉回满帧。6. 强化渲染管线的两个技巧色彩空间切换与参考像素自检6.1 把 BT.601 和 BT.709 都做成着色器常量不同视频源使用的色彩矩阵不一样标清内容常见 BT.601高清内容常见 BT.709。硬编码一套系数虽然省事但换片源后颜色偏灰或偏艳用户又说不清哪里不正常只能靠对着屏幕猜玄学。把矩阵系数设为着色器常量运行时切换就不需要重新编译 shader。HLSL 里改成常数寄存器float3 coefY : register(c0); float3 coefU : register(c1); float3 coefV : register(c2); float4 PS(float2 uv : TEXCOORD0) : COLOR0 { float y tex2D(texY, uv).r; float u tex2D(texU, uv).r - 0.5f; float v tex2D(texV, uv).r - 0.5f; float r y coefV.x * v; float g y coefU.y * u coefV.y * v; float b y coefU.z * u; return float4(saturate(r), saturate(g), saturate(b), 1.0f); }设置 BT.601 时coefV.x 是 1.402coefU.y 是 -0.344coefV.y 是 -0.714coefU.z 是 1.772。设置 BT.709 时换一组系数coefV.x 为 1.5748coefU.y 为 -0.1873coefV.y 为 -0.4681coefU.z 为 1.8556。把这两组放在一个枚举加开关里对外暴露一个接口就够了。6.2 用后台缓冲回读验证像素值颜色出问题时取一个像素值对比源数据是最快定位方法。D3D9 里可以拿后台缓冲和系统内存表面做 StretchRect然后读像素IDirect3DSurface9* pBack nullptr; g_pD3D9Device-GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, pBack); IDirect3DSurface9* pCopy nullptr; g_pD3D9Device-CreateOffscreenPlainSurface(width, height, D3DFMT_A8R8G8B8, D3DPOOL_SYSTEMMEM, pCopy, nullptr); g_pD3D9Device-StretchRect(pBack, nullptr, pCopy, nullptr, D3DTEXF_NONE); D3DLOCKED_RECT lock; pCopy-LockRect(lock, nullptr, D3DLOCK_READONLY); BYTE* pixel (BYTE*)lock.pBits y * lock.Pitch x * 4; // 此时 pixel[0]B, pixel[1]G, pixel[2]R pCopy-UnlockRect();StretchRect 要求两个表面格式完全一致所以回读表面格式最好用后台缓冲相同的格式。D3D11 对应做法是先把后台缓冲 CopyResource 到一个 STAGING 纹理再 Map 读取。这套自检方法我保留在工具类里任何一次颜色调整后先读像素再谈观感。最后说一个血泪教训我第一次做这个功能时把 V 和 U 顺序搞反全屏泛绿三天都在查显卡驱动和纹理格式最后打印源数据首行才发现是色度平面顺序问题。从那以后任何 YUV 渲染工程我上手第一件事就是先打印三个平面的前 16 个字节格式对不对一眼就能看出来。这个习惯帮我避开了后续很多换解码库时的隐性变更希望帮到你。本文还有配套的精品资源点击获取