☰
D3D 视频渲染实战:YV12 转 RGB 与绿屏排查
2026/10/20 16:14:26 网站建设 项目流程

简介:这是一份面向Windows平台音视频开发者的D3D视频渲染源码工程,重点解决YV12等YUV格式在GPU上的高效显示问题。工程基于Direct3D与Shader实现像素格式转换,可同时支持YV12、I420、NV12、YUY2、UYVY以及RGB24、RGB32、RGB555、RGB565等多种像素格式,并额外提供半透明文本叠加能力,适合需要自研播放器渲染层或研究GPU视频上屏的中高级开发者参考。压缩包共72个文件,约1.05MB,以36个h头文件与9个cpp实现文件为核心,辅以dll、lib、vcproj、sln等工程与依赖文件,另有exe测试程序、rc资源脚本及ReadMe说明,目录按D3DManager、Display、Shader等模块拆分,结构清晰。代码在Windows XP SP2、DXSDK9.0c与9800GT环境下测试通过,已有966人学习下载。读者可从中获取完整的D3D渲染管线搭建思路、各格式Shader实现与文本叠加方案,便于二次开发与排错。

1. 从一次播放器绿屏说起:YV12 与 D3D 渲染到底怎么配合

某次帮朋友排查一个本地播放器 Demo,窗口一打开就是满屏荧光绿,音频正常、进度条正常,唯独画面像被泼了油漆。抓了帧一看,解码器吐出来的是 YV12 数据,渲染层却按 RGB 的套路去采样,UV 分量被当成了亮度,颜色自然全乱。这个场景在视频渲染里非常典型:解码端输出 YUV 系格式,显示端却只认 RGB,中间那层转换如果交给 CPU 逐像素做,1080p 还能扛,4K 高帧率就直接把 CPU 吃满。基于 D3D 的 YV12 视频渲染,核心思路就是把 Y、U、V 三个平面直接上传成纹理,用 GPU 的像素着色器完成 YUV 到 RGB 的矩阵变换,让显卡替 CPU 干这件脏活。它适合做本地播放器、监控预览、视频会议客户端的同学,尤其是那些已经用上 D3D9 或 D3D11、想把渲染管线从软解软渲升级到硬解硬渲的场景。下面按“数据长什么样 → 纹理怎么建 → Shader 怎么写 → 坑在哪”的顺序拆一遍。

2. YV12 的内存布局与 D3D 纹理映射:先把数据喂对

2.1 YV12 三个平面到底怎么排

YV12 属于 YUV420 家族,名字里的 12 指的是每像素平均 12 bit。它的排布是三个独立平面:先是 Y 平面,宽高与画面一致;紧接着是 V 平面(注意顺序,YV12 是 Y-V-U,不是 Y-U-V),宽高各是画面的二分之一;最后是 U 平面,同样宽高减半。也就是说,一个 1920×1080 的帧,Y 占 1920×1080 字节,V 和 U 各占 960×540 字节,总大小是 1920×1080×1.5 字节。这里第一个容易翻车的地方就是平面顺序,很多资料把 YV12 和 I420 混着讲,I420 是 Y-U-V,YV12 是 Y-V-U,如果按 I420 的顺序去绑纹理,画面会偏色但不至于全绿,偏色方向能帮你反推是哪个平面接错了。

2.2 用 D3D9 创建三张纹理并上传

D3D9 时代最直接的做法是创建三张 L8 或 A8 格式的纹理,分别对应 Y、U、V,然后用UpdateSurface或LockRect把数据拷进去。下面这段是常见的初始化与上传逻辑,用 D3D9 举例,D3D11 的思路一致,只是换成ID3D11Texture2D加Map/Unmap。

// 假设 pDevice 是 IDirect3DDevice9*,width/height 是画面尺寸 IDirect3DTexture9* pTexY = nullptr; IDirect3DTexture9* pTexV = nullptr; IDirect3DTexture9* pTexU = nullptr; // Y 平面:全分辨率,L8 格式每像素 8 bit 亮度 pDevice->CreateTexture(width, height, 1, D3DUSAGE_DYNAMIC, D3DFMT_L8, D3DPOOL_DEFAULT, &pTexY, nullptr); // V/U 平面:半分辨率,注意 YV12 先 V 后 U pDevice->CreateTexture(width / 2, height / 2, 1, D3DUSAGE_DYNAMIC, D3DFMT_L8, D3DPOOL_DEFAULT, &pTexV, nullptr); pDevice->CreateTexture(width / 2, height / 2, 1, D3DUSAGE_DYNAMIC, D3DFMT_L8, D3DPOOL_DEFAULT, &pTexU, nullptr); // 上传一帧:pY/pV/pU 是解码器给出的三个平面首地址 // strideY 是 Y 平面行跨度,strideUV 是 UV 平面行跨度 D3DLOCKED_RECT rect; pTexY->LockRect(0, &rect, nullptr, D3DLOCK_DISCARD); for (int y = 0; y < height; ++y) { memcpy((BYTE*)rect.pBits + y * rect.Pitch, pY + y * strideY, width); } pTexY->UnlockRect(0); // V 和 U 同理,注意宽高是 width/2、height/2

逻辑说明:D3DFMT_L8表示单通道 8 位亮度纹理,正好装 Y/U/V 的一个分量。D3DUSAGE_DYNAMIC配合D3DLOCK_DISCARD是为了每帧更新时不阻塞 GPU,这是播放场景的常规做法。参数上要盯住两个:rect.Pitch是 D3D 给纹理分配的行跨度,可能大于width,所以必须按Pitch逐行拷贝,不能一次性memcpy整块;strideY是解码器输出缓冲的行跨度,同样可能大于width,两个跨度不一致是花屏的常见根源。

2.3 D3D11 下的纹理创建差异

D3D11 里没有 L8 这种“老格式”,通常用DXGI_FORMAT_R8_UNORM建三张Texture2D,绑定为D3D11_BIND_SHADER_RESOURCE,更新时用UpdateSubresource或动态纹理加Map。这里有个细节:D3D11 的Map返回的RowPitch同样可能大于宽度,逐行拷贝的习惯要保留。另外 D3D11 不允许把R8_UNORM直接当渲染目标,所以三张纹理只做采样输入,最终颜色输出到一张B8G8R8A8_UNORM的 back buffer 上,这一步和 D3D9 的StretchRect思路不同,别照搬。

3. 像素着色器里的 YUV 到 RGB:矩阵、采样与色彩范围

3.1 转换矩阵不是唯一的,BT.601 和 BT.709 要选对

YUV 转 RGB 的公式取决于色彩标准。标清内容多用 BT.601,高清内容多用 BT.709,两者矩阵系数不同。如果拿 BT.601 的系数去转 BT.709 的素材,肤色会偏,整体发灰或发暗。常见做法是:分辨率小于等于 576p 用 BT.601,大于 576p 用 BT.709,或者直接读解码器 metadata 里的color_space字段。下面给出 BT.709 的着色器实现,BT.601 只需替换矩阵系数。

// 顶点着色器:一个全屏 quad,输出 UV struct VS_OUTPUT { float4 pos : POSITION; float2 uv : TEXCOORD0; }; sampler2D texY : register(s0); sampler2D texV : register(s1); sampler2D texU : register(s2); float4 mainPS(VS_OUTPUT input) : COLOR { float y = tex2D(texY, input.uv).r; float v = tex2D(texV, input.uv).r - 0.5; // 色度以 0.5 为中心 float u = tex2D(texU, input.uv).r - 0.5; // BT.709 有限范围(16-235)转 RGB y = (y - 0.0625) * 1.164; // 16/255 ≈ 0.0625 float r = y + 1.792 * v; float g = y - 0.213 * u - 0.533 * v; float b = y + 2.112 * u; return float4(saturate(r), saturate(g), saturate(b), 1.0); }

逻辑说明:tex2D采样三张纹理,UV 坐标共用同一个,因为 Y、U、V 在归一化坐标下位置是对应的。色度减 0.5 是因为 8 bit 色度以 128 为中性值。y先做有限范围到全范围的缩放,1.164是255/219的近似。三个系数来自 BT.709 矩阵。参数上要留意:如果解码器输出的是全范围(0-255),y那行要去掉减 0.0625 和乘 1.164,否则画面会过曝。saturate是防止矩阵运算后溢出,但如果你要做 HDR 或后续处理,这里可以先不 clamp,留到后面统一处理。

3.2 采样坐标的偏移与半像素问题

YUV420 的 U、V 平面是半分辨率,采样时如果直接用 Y 的 UV 坐标去采 U、V,在 D3D9 的纹理过滤下通常没问题,因为硬件会自动按比例映射。但在某些驱动上,半分辨率纹理的采样点会偏半个像素,导致边缘出现彩色镶边。常见做法是在采样 U、V 时加一个半像素偏移,或者把纹理过滤设为D3DTEXF_POINT先验证是不是过滤导致的。如果点采样正常、线性采样偏色,那就是过滤插值把相邻色度混进来了,这时候要么接受轻微模糊,要么在 Shader 里手动做双线性插值。

3.3 用一张纹理还是三张纹理

三张纹理是最直观的方案,但有些实现会把 Y、U、V 打包进一张纹理的不同通道,比如 Y 放 R 通道、U 放 G、V 放 B,这样只需要一次采样。打包的好处是减少纹理切换和采样次数,坏处是上传时要多做一次重排,CPU 开销增加。我一般会先上三张纹理的方案,跑通之后再根据性能分析决定要不要打包。如果 GPU 采样不是瓶颈,三张纹理的可读性和调试便利性更好,出问题时能单独看某一张纹理的内容。

4. 避坑与排查:绿屏、花屏、偏色到底查哪里

4.1 现象:满屏绿色,音频正常

原因:UV 平面顺序接反,或者 U、V 纹理绑到了错误的 sampler。YV12 是 Y-V-U,如果按 Y-U-V 绑,V 当 U 用、U 当 V 用,颜色会严重偏移,绿色是最常见的表现。解决:打印三个平面首地址的前几个字节,和已知正确的帧对比;或者在 Shader 里临时把 U、V 对调,看颜色是否恢复正常,如果是,就说明绑反了。

4.2 现象:画面花屏,有斜纹或错位

原因:行跨度没对齐。解码器输出的strideY和 D3D 纹理的Pitch不一致,逐行拷贝时用了错误的跨度。解决:在拷贝循环里分别打印strideY和rect.Pitch,确认每一行的源地址和目标地址都按各自的跨度递增。常见错误是源用了width、目标用了Pitch,或者反过来。

4.3 现象:颜色发灰、发暗,对比度低

原因:色彩范围判断错误。有限范围(16-235)的素材被当成全范围(0-255)处理,或者反过来。解决:查解码器 metadata 里的video_full_range_flag,或者用一张标准彩条图测试,看黑白是否落在 16 和 235 附近。如果黑是 0、白是 255,就是全范围;如果黑是 16、白是 235,就是有限范围。

4.4 现象:4K 下帧率骤降,GPU 占用不高但 CPU 满载

原因:每帧都在做LockRect加逐行memcpy,CPU 拷贝成了瓶颈。解决:改用动态纹理加D3DLOCK_DISCARD,或者用 D3D11 的Map加D3D11_MAP_WRITE_DISCARD,让驱动做零拷贝或异步更新。如果解码器支持输出到 D3D 纹理(比如某些硬解接口),直接共享纹理,连拷贝都省掉。

4.5 现象:窗口缩放后画面拉伸或黑边

原因:全屏 quad 的顶点坐标和视口没对应上,或者纹理采样坐标没按窗口比例调整。解决:检查顶点着色器输出的pos是否覆盖整个视口,uv是否从 0 到 1。如果要做保持宽高比,在顶点阶段按窗口比例缩放 quad,而不是在 Shader 里改 UV。

5. 进阶技巧:用一张 R8G8B8A8 纹理打包 YUV 并验证

跑通三张纹理之后,如果想把采样次数压到一次,可以把 Y、U、V 打包进一张R8G8B8A8纹理:R 通道放 Y,G 通道放 U,B 通道放 V,A 通道留空或放 alpha。上传时在 CPU 侧做一次重排,把三个平面的数据交错写入。这样做的好处是 Shader 里只需一次tex2D,坏处是 CPU 重排有开销,适合 GPU 采样是瓶颈、CPU 还有余量的场景。

// 把 YV12 三个平面打包进一张 RGBA 纹理 // pDst 是锁定的纹理内存,dstPitch 是行跨度 for (int y = 0; y < height; ++y) { BYTE* dstRow = pDst + y * dstPitch; const BYTE* yRow = pY + y * strideY; // UV 平面行号是 y/2,因为半分辨率 const BYTE* vRow = pV + (y / 2) * strideUV; const BYTE* uRow = pU + (y / 2) * strideUV; for (int x = 0; x < width; ++x) { dstRow[x * 4 + 0] = yRow[x]; // R = Y dstRow[x * 4 + 1] = uRow[x / 2]; // G = U dstRow[x * 4 + 2] = vRow[x / 2]; // B = V dstRow[x * 4 + 3] = 255; // A = 不透明 } }

逻辑说明:外层按行遍历,内层按像素遍历。UV 的行号是y/2,列号是x/2,因为半分辨率。dstPitch是目标纹理的行跨度,同样可能大于width*4,所以目标地址要按dstPitch递增。这个循环是 CPU 密集的,1080p 一帧大约 200 万次迭代,4K 是 800 万次,如果每帧都做,CPU 占用会明显上升。优化方向是用 SIMD 指令一次处理多个像素,或者把重排放到 GPU 的 compute shader 里做。

验证打包是否正确,最简单的办法是在 Shader 里把 R、G、B 通道分别输出成灰度图,看 Y、U、V 是否各自正确。如果 R 通道输出的灰度图是正常的亮度画面,G 和 B 通道是模糊的色度画面,说明打包没错。如果 R 通道出现条纹或错位,那就是行跨度或列索引算错了。从那以后我每次做 YUV 渲染,都会先写一个只输出单通道的调试 Shader,确认三个平面各自的位置和跨度都对,再上完整的转换矩阵。这个习惯帮我省掉了大量对着绿屏猜原因的时间。希望帮到你。

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

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

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

立即咨询