简介:一套基于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 × H | W × H |
| V | Y 之后 | (W/2) × (H/2) | W × H / 4 |
| U | V 之后 | (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 路径作为默认渲染,代码结构更清晰,后续如果要迁移到 D3D12,D3D11 的着色器和资源管理思路可以直接平移。
如果只是给内部工具加个视频预览,直接上 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 到 1,Y、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 planar,Y 平面完全一样,区别在色度: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 / 2,Height = 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。
Texture2D<float> texY : register(t0); Texture2D<float> texU : register(t1); Texture2D<float> 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.402,coefU.y 是 -0.344,coefV.y 是 -0.714,coefU.z 是 1.772。设置 BT.709 时,换一组系数:coefV.x 为 1.5748,coefU.y 为 -0.1873,coefV.y 为 -0.4681,coefU.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 个字节,格式对不对一眼就能看出来。这个习惯帮我避开了后续很多换解码库时的隐性变更,希望帮到你。
本文还有配套的精品资源,点击获取