简介:基于D3D的YUV视频渲染源码,面向需要在Windows平台实现GPU加速视频显示的开发者和图形学爱好者。工程完整支持YV12、I420、NV12、YUY2、UYVY、RGB24、RGB32、RGB555、RGB565等多种输入,借助Shader完成YUV到RGB的GPU转换,并提供半透明文本叠加能力,可广泛用于播放器渲染、视频监控、图像处理等场景。压缩包共72个文件,以h头文件与cpp源文件为主,辅以dll、lib、exe和sln等工程与运行文件,整体仅1.05MB,结构清晰,便于直接阅读源码和编译验证。已有965人学习下载。源码中包含了D3D设备管理、渲染工厂、着色器代码等模块,可学习D3D渲染管线搭建、像素着色器编写、多格式适配以及文本叠加实现等关键技巧,适合有D3D基础并希望深入GPU视频渲染的读者。
1. 基于 D3D 的 YV12 视频渲染到底在解决什么问题
把“基于 D3D 的 YV12 视频渲染”拆开看,是两件经常被放一起讲、却被混为一谈的事:解码器吐出来的视频帧是 YV12,显示窗口最终要的是 RGB,而 D3D 不会帮你完成中间的转换。
早期不少工具图省事,让 CPU 把 YV12 逐像素转成 RGB 再贴纹理,单路还凑合,多路预览和 4K 分辨率下 CPU 占用和延时立刻暴露。D3D 渲染方案的核心,是把 YUV 到 RGB 的转换挪进 GPU 着色器,用三张 R8 纹理分别放 Y、U、V 平面,既能省掉 CPU 转换,也让多路输出更好控。
它适合正在做播放器、视频预览、多路监控或回显模块的人。下文按 D3D11 为主线给出一套可直接落地的做法,包括参数怎么定、采样怎么换、五个常见的坑和验证技巧。
2. 渲染链路怎么搭:先吃透 YV12 内存布局和 D3D 接口代次
2.1 YV12 三个平面:先搞清楚 Y、V、U 顺序和行对齐
YV12 是 planar YUV 4:2:0,一帧被拆成三个独立平面:Y 平面保存完整亮度,V 和 U 平面分别保存色差。所谓 4:2:0,指色度在两个方向都做一半采样,所以 V、U 平面只有 Y 平面的四分之一面积。很多文档里把平面顺序写成 Y、V、U,而另一些解码器输出的是 Y、U、V,也就是常说的 420P。这个顺序差异是后面很多颜色问题的根因,最好在接口层就固定下来,而不是在调用处临时换。
| 平面 | 常见大小 | 内容 |
|---|---|---|
| Y | width × height | 亮度,每像素 1 字节 |
| V | (width/2) × (height/2) | 色差 V,每像素 1 字节 |
| U | (width/2) × (height/2) | 色差 U,每像素 1 字节 |
内存布局看起来很规整,实际开发里真正容易翻车的是行对齐。视频帧每一行占用的字节数不一定是画面宽度,很多采集卡和解码器都会把行距补到 16、32 或 64 字节对齐。比如某路 1280×720 的采集帧,Y 平面行距可能正好是 1280,也可能给成 1344,U/V 平面的行距则一般是 Y 平面行距的一半。
因此地址换算必须依赖外部传入的 pitch,而不是自己拿 width 去乘。一个稳妥的约定是:
UINT ySize = pitchY * height; // Y 平面实际字节数 UINT uvHeight = (height + 1) / 2; // 奇数高度时向上取整 UINT uvWidth = (width + 1) / 2; UINT uSize = pitchUV * uvHeight; UINT vSize = pitchUV * uvHeight;这里pitchY是 Y 平面每行字节数,pitchUV是 U/V 平面每行字节数。如果解码器只给了strideY,通常可以按pitchUV = pitchY / 2估算,但最保险的做法还是让解码器把色度平面行距也一起返回。很多只按 width 写循环的老代码,在高分辨率下会看到每行逐步偏移、画面变成斜纹,原因就在这个参数没接对。
2.2 D3D9、D3D11、D3D12 怎么选
D3D 是一簇接口代次,具体选哪个会直接影响纹理创建和着色器写法。D3D9 老项目存量多,很多播放器链路还是它,但对 YV12 这类平面格式没有统一的最优路径,驱动差异明显,新代码不建议往 D3D9 上搭。D3D11 是目前新启动模块里最常见的选择,接口稳定,创建动态纹理、绑定 Shader Resource View、写 HLSL 都比较直接。D3D12 性能和可控性更强,但资源状态、同步、命令行都要自己管,做一个 YV12 渲染付出的复杂度不划算。
| 维度 | D3D9 | D3D11 | D3D12 |
|---|---|---|---|
| 平面纹理支持 | 依赖驱动,兼容性一般 | 三张 R8 纹理稳定 | 支持,但状态管理复杂 |
| Shader 编写 | 模型老,可读性差 | HLSL 直观,便于做矩阵切换 | HLSL 同样直观 |
| 上手成本 | 低,但坑多 | 中等,资料多 | 偏高 |
| 多路渲染扩展 | 一般 | 好 | 好 |
本文下面按 D3D11 展开。如果老工程只能停在 D3D9,核心思路仍然成立:三张 R8 纹理、一个 YUV 到 RGB 的像素着色器,只是接口换成旧版本。不要一上来就怀疑「YV12 有没有原生纹理格式」,D3D11 没有可直接绑定的 YV12 单纹理格式,或者说驱动层面的支持很难跨设备保持一致。通用的做法就是拆平面,每张纹理只放一个通道。
2.3 纹理数量:一个 RGB 纹理转好,还是三份 R8 纹理分开采样
常见做法有三条路可走。
第一条是每帧先把 YV12 在 CPU 里转成 RGB 数组,再上传一张 R8G8B8A8 纹理。代码最简单,颜色转换也容易控制,但 1080P 下每帧要做百万级像素的三通道运算,多路视频时 CPU 很快被打满。这个方案适合临时 Demo,不适合长期增量。
第二条是把 YV12 在 CPU 里重新排布成 NV12,利用驱动对 NV12 的部分支持,再交给后续处理。节省的 CPU 有限,因为排布本身也是一次拷贝,而且不同驱动对 NV12 纹理的采样行为仍有差异,调试成本偏高。
第三条就是本文采用的:创建三张DXGI_FORMAT_R8_UNORM纹理,分别存 Y、U、V 平面,在像素着色器里完成矩阵换算。GPU 做一次乘加运算的代价远低于 CPU 转格式,显存占用也只是原始数据的大小,多路场景扩展性好。
D3D11_TEXTURE2D_DESC desc = {}; desc.Width = planeWidth; desc.Height = planeHeight; desc.MipLevels = 1; desc.ArraySize = 1; desc.Format = DXGI_FORMAT_R8_UNORM; desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_DYNAMIC; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; ID3D11Texture2D* planeTex = nullptr; pDevice->CreateTexture2D(&desc, nullptr, &planeTex);这里planeWidth和planeHeight必须按平面尺寸传。Y 平面用视频宽高,U/V 平面用(width+1)/2和(height+1)/2。Usage选DYNAMIC是为了每帧 Map 写入时不重新创建纹理;BindFlags只给SHADER_RESOURCE,不需要渲染目标,因为我们不在 GPU 里二次渲染。三张纹理各配一个 Shader Resource View,之后就能直接被像素着色器采样。
3. 在 D3D11 里渲染 YV12 的最小整套流程
3.1 创建三张 plane 纹理和着色器资源视图
上一章只创建了纹理本体,D3D11 里要让 shader 读到它,还需要为每张纹理创建ID3D11ShaderResourceView。这一步通常封装成一个函数,创建纹理和 SRV 一起完成,避免纹理和视图生命周期不一致。
HRESULT CreatePlaneTextureAndView( ID3D11Device* d3dDevice, UINT width, UINT height, ID3D11Texture2D** texture, ID3D11ShaderResourceView** srv) { D3D11_TEXTURE2D_DESC texDesc = {}; texDesc.Width = width; texDesc.Height = height; texDesc.MipLevels = 1; texDesc.ArraySize = 1; texDesc.Format = DXGI_FORMAT_R8_UNORM; texDesc.SampleDesc.Count = 1; texDesc.Usage = D3D11_USAGE_DYNAMIC; texDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE; texDesc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; HRESULT hr = d3dDevice->CreateTexture2D(&texDesc, nullptr, texture); if (FAILED(hr)) return hr; D3D11_SHADER_RESOURCE_VIEW_DESC viewDesc = {}; viewDesc.Format = DXGI_FORMAT_R8_UNORM; viewDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; viewDesc.Texture2D.MipLevels = 1; hr = d3dDevice->CreateShaderResourceView(*texture, &viewDesc, srv); return hr; }调用时注意参数:Y 纹理传视频宽高,U、V 纹理传半宽半高。三个 SRV 创建完成后,保存到srvY、srvU、srvV三个成员里。这样纹理创建的职责就闭环了,后面每帧更新只碰数据和绘制,不再反复创建资源。
3.2 HLSL 转换着色器:把 BT.601 和 BT.709 参数化
像素着色器负责把三个平面合成 RGB。关键点有两个:一是采样器的 UV 坐标要对齐,二是 YUV 到 RGB 的矩阵必须参数化。很多人直接把 BT.601 limited range 写死,遇到高清视频或者采集卡 full range 输出时,画面发灰或者对比度奇怪,查半天才发现是矩阵选错了。
下面是一份可直接用的 HLSL,同时覆盖最多见的三种情况:BT.601 studio swing、BT.709 studio swing、BT.601 full range。矩阵参数通过常量缓冲传入,运行时不需要重编 shader。
Texture2D<float> texY : register(t0); Texture2D<float> texU : register(t1); Texture2D<float> texV : register(t2); SamplerState yuvSampler : register(s0); cbuffer YuvCb : register(b0) { int matrixType; // 0=BT.601 limited, 1=BT.709 limited, 2=BT.601 full int pad0; }; struct VS_INPUT { float3 pos : POSITION; float2 uv : TEXCOORD0; }; struct VS_OUTPUT { float4 pos : SV_Position; float2 uv : TEXCOORD0; }; VS_OUTPUT vsMain(VS_INPUT vin) { VS_OUTPUT vout; vout.pos = float4(vin.pos, 1.0f); vout.uv = vin.uv; return vout; } float4 psMain(VS_OUTPUT pin) : SV_Target { float y8 = texY.Sample(yuvSampler, pin.uv).r * 255.0f; float u8 = texU.Sample(yuvSampler, pin.uv).r * 255.0f; float v8 = texV.Sample(yuvSampler, pin.uv).r * 255.0f; float3 rgb; if (matrixType == 0) { rgb.x = 1.164f * (y8 - 16.0f) + 1.596f * (v8 - 128.0f); rgb.y = 1.164f * (y8 - 16.0f) - 0.392f * (u8 - 128.0f) - 0.813f * (v8 - 128.0f); rgb.z = 1.164f * (y8 - 16.0f) + 2.017f * (u8 - 128.0f); } else if (matrixType == 1) { rgb.x = 1.164f * (y8 - 16.0f) + 1.793f * (v8 - 128.0f); rgb.y = 1.164f * (y8 - 16.0f) - 0.213f * (u8 - 128.0f) - 0.533f * (v8 - 128.0f); rgb.z = 1.164f * (y8 - 16.0f) + 2.112f * (u8 - 128.0f); } else { rgb.x = y8 + 1.402f * (v8 - 128.0f); rgb.y = y8 - 0.344f * (u8 - 128.0f) - 0.714f * (v8 - 128.0f); rgb.z = y8 + 1.772f * (u8 - 128.0f); } return float4(rgb * (1.0f / 255.0f), 1.0f); }这段逻辑是把采样的[0,1]值还原成 8 bit 码值,再做有限范围或全范围的矩阵运算,最后转成[0,1]输出。很多人把公式里的16和128去掉,结果仍然是能出图,黑位和淡色却不对,这就是范围问题。BT.601 和 BT.709 的差值主要体现在 V 通道系数,高清 1080P 以上多数用 BT.709,标清内容常见 BT.601,不要一刀切。
matrixType用常量缓冲传入。C++ 侧结构体可以是两个 int,保持 16 字节对齐;更新时直接UpdateSubresource即可。
3.3 每帧更新 plane 并绘制
纹理是动态的,所以每帧要用 Map 写入解码器刚给的 YV12 数据。Map 时用D3D11_MAP_WRITE_DISCARD,因为每帧内容完全覆盖,没必要保留旧数据。写入时以行为单位拷贝,行距用解码器给的srcPitch,目标行距用mapped.RowPitch。
void UpdatePlane( ID3D11ImmediateContext* ctx, ID3D11Texture2D* texture, const uint8_t* src, UINT srcPitch, UINT width, UINT height) { D3D11_MAPPED_SUBRESOURCE mapped = {}; HRESULT hr = ctx->Map(texture, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped); if (FAILED(hr)) return; for (UINT row = 0; row < height; ++row) { memcpy( (uint8_t*)mapped.pData + row * mapped.RowPitch, src + row * srcPitch, width); } ctx->Unmap(texture, 0); }width在这里是有效字节数,不是视频宽度。对 Y 平面它等于画面宽度,对 U/V 平面它等于(width+1)/2。mapped.RowPitch是 D3D11 内部行距,可能和srcPitch不同,所以两边的行距都不能省。
绘制前把三个 SRV 绑到 t0、t1、t2,注意 shader 里约定 t1 是 U、t2 是 V。如果你的数据源是标准 YV12 顺序 Y+V+U,那就在传给UpdatePlane之前把 U/V 指针交换成 Y、U、V 再绑定,不要让 shader 去猜内存顺序。
void RenderFrame( ID3D11ImmediateContext* ctx, const uint8_t* yData, const uint8_t* uData, const uint8_t* vData, UINT pitchY, UINT pitchUV, UINT width, UINT height) { UINT uvW = (width + 1) / 2; UINT uvH = (height + 1) / 2; UpdatePlane(ctx, texYTexture, yData, pitchY, width, height); UpdatePlane(ctx, texUTexture, uData, pitchUV, uvW, uvH); UpdatePlane(ctx, texVTexture, vData, pitchUV, uvW, uvH); ID3D11ShaderResourceView* srvs[] = { srvY, srvU, srvV }; ctx->PSSetShaderResources(0, 3, srvs); UINT stride = sizeof(Vertex); UINT offset = 0; ctx->IASetVertexBuffers(0, 1, &g_vertexBuffer, &stride, &offset); ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); ctx->IASetInputLayout(g_inputLayout); ctx->VSSetShader(g_vertexShader, nullptr, 0); ctx->PSSetShader(g_pixelShader, nullptr, 0); ctx->PSSetConstantBuffers(0, 1, &g_yuvCb); ctx->Draw(6, 0); }渲染一个覆盖全窗口的四边形时,顶点缓冲里放两个三角形六个点,坐标用裁剪空间,UV 从左上角 0,0 到右下角 1,1。光栅化状态建议把 CullMode 设为D3D11_CULL_NONE,避免四边形绕序不一致时被背面剔除。这样每一帧的链路就是:Map 写三个平面 → 绑 SRV → 设 shader → Draw。能不出现在渲染循环里的资源创建,都不要放循环里。
4. 渲染参数与显示细节:范围、矩阵、采样器、拉伸
4.1 影响画面的几个关键参数
YV12 渲染颜色对与不对,通常不是代码结构问题,而是参数问题。我把几个必调的参数列成一张表,遇到颜色异常先看这里。
| 参数 | 作用 | 建议 |
|---|---|---|
matrixType | YUV → RGB 矩阵 | 标清用 BT.601,高清用 BT.709,采集卡输出 0-255 用 full range |
pitchY / pitchUV | 行距对齐 | 优先由解码器返回,不自己用 width 估 |
| U/V 绑定顺序 | 决定颜色通道是否互换 | 统一约定 shader 的 t1 是 U,t2 是 V |
| 采样器过滤 | 缩放时是否平滑 | 放大用线性采样,调试 Y 平面用点采样更清晰 |
| 渲染目标格式 | 是否二次编码颜色 | 优先用非 sRGB 格式或做对应线性化 |
采样器一般用一个即可。需要放大到窗口全屏时,D3D11_FILTER_MIN_MAG_LINEAR_MIP_POINT能避免明显的马赛克;如果只是 1:1 显示、调试单像素问题时,切到MIN_MAG_POINT更便于观察边缘。把采样器做成可切换的成员,调试时少很多事。
渲染目标格式也要留意。常见 D3D11 交换链用DXGI_FORMAT_R8G8B8A8_UNORM,shader 输出什么就是什么。有些人为了省一步颜色管理把交换链设成UNORM_SRGB,视频转换出来的是 sRGB 编码值,再经过 sRGB 渲染目标二次编码,画面会明显发灰、发亮。这种情况不是矩阵选错,是颜色空间路径不一致。
4.2 窗口拉伸:让视频保持原始宽高比
简单把四边形拉满窗口,会产生横向或纵向拉伸。视频预览模块一般要求等比缩放,多出的区域留黑边。做法是计算视频宽高比和窗口宽高比,再设置一个带偏移的 Viewport。
void SetVideoViewport( ID3D11ImmediateContext* ctx, UINT videoWidth, UINT videoHeight, UINT clientWidth, UINT clientHeight) { float videoAspect = (float)videoWidth / (float)videoHeight; float clientAspect = (float)clientWidth / (float)clientHeight; D3D11_VIEWPORT vp = {}; vp.MinDepth = 0.0f; vp.MaxDepth = 1.0f; if (videoAspect > clientAspect) { vp.Width = (float)clientWidth; vp.Height = vp.Width / videoAspect; vp.TopLeftX = 0.0f; vp.TopLeftY = (clientHeight - vp.Height) * 0.5f; } else { vp.Height = (float)clientHeight; vp.Width = vp.Height * videoAspect; vp.TopLeftX = (clientWidth - vp.Width) * 0.5f; vp.TopLeftY = 0.0f; } ctx->RSSetViewports(1, &vp); }这里只改了 Viewport,不会影响 Clear 区域。背景黑边要先用ClearRenderTargetView把整个渲染目标清成黑色,再在 Viewport 内绘制视频。窗口 Resize 时重新调用这个函数即可。如果是多路分割画面,可以把clientWidth和clientHeight换成每个分屏的矩形宽高,思路完全一致。
5. 基于 D3D 的 YV12 渲染避坑:五个翻车点和排查顺序
5.1 高频翻车现场:现象、原因、解法
第一个坑是颜色偏紫绿色,字幕和物体边缘渗色明显。原因是 YV12 和 YU12 的 U/V 平面顺序搞反了。很多解码器默认输出 Y+U+V,却沿用 YV12 这个叫法;真正的 YV12 是 Y+V+U。把 V 平面当成 U 绑定后,Cb 和 Cr 就换了。解决办法是在传入渲染层之前用枚举固定平面顺序,比如PlaneOrder_UV和PlaneOrder_VU,确保调用方知道自己在传什么。
第二个坑是画面斜向偏移或每行都有一点点错位。现象是整帧像被斜着切开,尤其是上下边缘出现色带。原因基本是把 pitch 当成了 width,下一行起点算错。解决方法是所有平面都按row * srcPitch定位,拷贝时只拷width个有效字节,而不是用 pitch 当拷贝长度。
第三个坑是画面整体发灰或者亮部过曝。这通常是色域范围问题。视频有 studio swing 和 full range 两种:studio swing 的 Y 在 16 到 235,full range 的 Y 在 0 到 255。如果源是 full range,却用 limited range 矩阵去放大,画面会显得扁平;反过来会偏黑色。decoder 参数里通常有这个字段,拿到后直接透传给matrixType,不要自己从像素分布猜。
第四个坑是黑屏和闪烁,特别是连续播放一段时间后会偶发。原因大多在 Map 的时机:上一帧 GPU 还在读纹理,当前帧又用WRITE_DISCARD覆盖,或者解码线程和渲染线程共用同一块缓冲区。解决办法是让解码线程把数据拷进自己的 staging buffer,渲染线程只读 buffer。更简单的做法是渲染线程自己持有最后一份拷贝,不做零拷贝,先保证稳定。
第五个坑是高分辨率多路时 CPU 和 GPU 占比一起升高。原因之一是每帧创建临时纹理和 SRV,另一个是UpdateSubresource在部分驱动上会等待 GPU。解决方向是纹理只创建一次,每帧只 Map 写数据;如果多路 4K 仍然吃力,把三张 R8 纹理换成一次性的 U 和 V 合并纹理,或者走更底层的 copy queue。多路优化不要一上来就做,先确认 CPU 时间是不是花在拷贝和 Map 上。
5.2 排查顺序:先隔离数据,再怀疑 D3D
遇到颜色问题,我一般建议先不查 shader。给 Y 平面填一张已知灰阶数据,比如整帧填 128,U/V 填 128,正常画面应该是中性灰。如果这一步出来的不是灰,说明纹理写入或绑定已经有问题,后面所有颜色判断都不可信。
第二步是直接把像素着色器改成只输出 Y 平面灰度,也就是return float4(y, y, y, 1.0f);。画面应该是正常的黑白亮度图。如果 Y 灰度图也花了,问题在 Y 平面数据或者动态纹理写入,和颜色矩阵无关。如果 Y 灰度图正常,再恢复 U/V 和矩阵,判断范围就小很多。
第三步是固定抓一帧数据反复渲染。比如把解码器停在任何一帧,把三个平面指针保存下来,连续调RenderFrame十次。如果同一帧数据每次都出不同颜色,就是时序问题;如果每次都一样但颜色不对,就是矩阵或顺序问题。固定帧是排除异步和 Map 竞争最快的手段。在代码里给RenderFrame加一个debugYOnly开关,比每次改 shader 重新编译高效得多。
6. 进阶技巧:给渲染加一个实时调参器和帧耗时统计
调参器不建议做成复杂 UI,一个键盘开关就够了。用 F1 循环切换matrixType,用 F2 打开 Y-only 灰度调试,这两个动作能覆盖大部分现场问题。不要把这些参数硬编码在 shader 里,而要放在常量缓冲和渲染接口参数里,这样遇到不同的采集卡和播放内容时,不用重新编 shader 就能救回来。
if (GetAsyncKeyState(VK_F1) & 1) g_matrixType = (g_matrixType + 1) % 3; if (GetAsyncKeyState(VK_F2) & 1) g_debugYOnly = !g_debugYOnly; g_yuvCb.matrixType = g_matrixType;帧耗时统计用QueryPerformanceCounter包住一次渲染调用,输出毫秒数。需要说明的是这是 CPU 视角的耗时,GPU 是异步的,真实 GPU 时间最好用 timestamp query;但 CPU 耗时足以判断 Map 拷贝是否异常,比如一帧由 0.5ms 涨到 5ms,大概率是等待驱动或缓存竞争。
验证颜色正确性,可以自己做一版已知色条:从 RGB 反推 YUV,把色条写进三个平面,再走渲染链路。色条里的红、绿、蓝、灰如果和原图一致,说明矩阵和范围都对;只有白和灰对、彩色不对,重点查 U/V 顺序;只有明暗不对,重点查 range。这个测试数据不依赖外部视频源,能稳定复现问题。
我保留的一个习惯是:每次把matrixType、U/V 顺序、pitch 三个参数都打成日志,在渲染前打印一次。这个习惯帮我在现场少走了很多弯路,也希望帮到你。
本文还有配套的精品资源,点击获取