十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

D3D11下YV12视频渲染实战:GPU加速YUV转RGB的完整方案

D3D11下YV12视频渲染实战:GPU加速YUV转RGB的完整方案 简介面向视频显示与播放开发的 Direct3D YUV 渲染示例工程支持 YV12、I420、NV12、YUY2、UYVY 及 RGB24、RGB32、RGB555、RGB565 等常见像素格式输入并在画面上实现半透明文本叠加便于播放器或监控客户端直接嵌入使用。工程基于 Windows XP SP2 与 DirectX SDK 9.0c 构建在 9800GT 显卡上验证通过适合希望掌握 GPU Shader 完成 YUV 到 RGB 转换、理解 D3D 渲染管线以及视频显示层封装的开发者。压缩包共 72 个文件以 36 个头文件和 9 个 C 源文件为核心覆盖 D3D 设备管理、显示工厂、不同格式的 Shader 定义、调试日志与字符绘制等模块同时附有可直接运行的 EXE、DLL、LIB 以及 Visual Studio 工程配置文件整体大小仅 1.05 MB结构清晰、体积轻量。目前已有 963 人学习下载。通过源码可对照 YUV420、YUV422、NV12、RGB24 等不同格式的 Shader 实现与渲染路径逐段理解渲染参数切换方法并可按项目需要裁剪模块快速迁移到自己的视频渲染框架中。 几个月前我在做一套多路视频播放工具解码器输出的帧清一色是YV12一开始图省事在CPU上做YV12到RGB32的转换结果4路1080p一上来CPU占用直接飙到70%以上界面卡得没法看。后来我把渲染链路整个挪到了D3D上用GPU做YUV到RGB的色彩转换和缩放CPU占用降到了个位数画面也顺滑了。这篇就把这套“基于D3D的YV12视频渲染”方案的完整思路和实操过程整理出来给正在跟视频帧渲染较劲的朋友做个参考。这个方案解决的核心问题是解码器输出的YV12帧如何在无需CPU逐像素转换的前提下以极低开销呈现在屏幕上。适合用DirectX做播放器、大屏拼接、视频分析终端、监控墙的开发者或者单纯想把YUV视频渲染性能再压一压的朋友。1. 项目核心思路为什么要把YV12丢给GPU1.1 YV12的反人类设计YV12属于平面格式Planar Format它不像RGB32那样把所有像素颜色值紧凑排在一起而是把一帧拆成三个独立的内存块先是完整的亮度平面Y然后是一块四分之一大小的色度平面V最后是另一块四分之一大小的色度平面U。Y平面宽×高每个像素一个字节表示亮度。V平面宽/2×高/2每个采样一个字节表示色差。U平面宽/2×高/2每个采样一个字节表示蓝色色差。对于1920×1080的帧Y平面占2073600字节U和V平面各占518400字节整帧大约3.1MB。如果直接在CPU上把它们拆开再逐个像素计算RGB4路1080p就是每秒上亿次运算不吃CPU才怪。1.2 两条技术路线CPU软转换还是GPU硬件渲染做YV12显示主要有两派做法先在CPU上调用libyuv或FFmpeg的swscale把YV12转成BGRA再上传到D3D纹理显示。好处是简单坏处是转换耗时、占用高。把YV12三个平面分别作为纹理上传到GPU在像素着色器里完成YUV到RGB的矩阵运算一次DrawCall直接输出到屏幕。好处是CPU几乎不参与像素级计算GPU并行处理天生适合这种像素级运算坏处是需要写HLSL着色器代码量稍大。我最终选了第二种方案。1080p的YV12帧上传三个纹理大约3MBGPU做色彩转换每帧大概0.1~0.3ms比CPU动辄3~5ms快了一个数量级。而且缩放、色彩空间转换、隔行处理都可以顺手在着色器里一起做整体架构非常干净。1.3 整体架构整个渲染链路分四段解码器输出YV12帧 → 分别拷贝到Y/U/V三张R8纹理 → 像素着色器做色彩空间转换 → 最终绘制到交换链后台缓冲。我选D3D11而不选D3D9的原因主要两点一是D3D11的纹理接口和着色器模型更干净R8_UNORM这种单通道格式支持得很完整二是D3D9的老式overlay表面虽然也支持YV12但硬件兼容性参差不齐新驱动上经常被禁用反面教材吃得太多了。2. YV12格式解析与YUV到RGB的转换公式2.1 内存布局和地址计算在真正写上传代码前必须先把YV12的内存布局算清楚。以1920×1080为例Y数据偏移0长度1920×1080。V数据偏移1920×1080长度960×540。U数据偏移1920×1080960×540长度960×540。特别注意V和U的顺序是V在前、U在后这和I420也叫IYUV刚好相反。很多人在这一步踩坑上传后画面偏色、颜色发绿多半是UV平面顺序颠倒了。如果用的是FFmpeg解码解码器输出的AVFrame默认可能是I420而不是YV12需要检查frame-data和frame-linesize的赋值关系必要时做一次平面重排。另外解码器输出的数据往往有对齐要求linesize行字节数通常不等于宽度比如宽1920的行可能按32字节对齐实际上一行可能是1920也可能是1920padding。上传纹理时不能无脑memcpy要按行拷贝否则画面边缘会有斜条纹。2.2 YUV到RGB的矩阵运算到底该用哪一套YUV到RGB的转换不是只有一种公式BT.601和BT.709两套矩阵对应不同清晰度标准用错了画面色彩立刻偏淡或偏浓。BT.601主要对应标清DVDBT.709对应高清720p/1080p现在绝大多数高清视频源用BT.709。最常用的BT.709有限范围公式如下// uv.x U采样值uv.y V采样值每个值的范围都是0~255 float Y texY.Sample(samY, uv).r * 255.0; float U texU.Sample(samU, uv).r * 255.0 - 128.0; float V texV.Sample(samV, uv).r * 255.0 - 128.0; float R Y 1.5748 * V; float G Y - 0.1873 * U - 0.4681 * V; float B Y 1.8556 * U;如果你用的是BT.601矩阵系数就换一套float R Y 1.402 * V; float G Y - 0.344 * U - 0.714 * V; float B Y 1.772 * U;两套矩阵的差异主要体现在G通道的U和V系数上BT.709的绿色权重分配和高清格式更匹配。实际项目中我会加一个可配置的开关根据输入视频的分辨率或者解码器提供的色彩信息动态选择矩阵不做死。2.3 色度下采样带来的边缘问题YV12中UV平面只有Y平面四分之一的尺寸采样时最常见的错误是直接让U/V纹理和Y纹理使用同一组UV坐标那样画出来的图像边缘会有明显的锯齿和偏色。正确的做法是像素着色器里以Y纹理的坐标为准U/V纹理采样时要对坐标做一步映射因为U/V纹理宽度是Y纹理的一半采样它的tex.Sample时会自动用U/V纹理自身的尺寸做归一化所以坐标其实不用人为除以2。但要注意采样器不要开过于激进的过滤Y平面建议用双线性U/V平面用点采样或双线性都行。实测下来U/V用双线性可以让边缘更平滑但颜色串扰也更明显要锐利的话U/V用点采样更稳。3. D3D11渲染管线实操从纹理上传到像素着色器3.1 用R8纹理承载三个平面D3D11里做YV12渲染的常见姿势是创建三张DXGI_FORMAT_R8_UNORM纹理分别对应Y、U、V。R8_UNORM是单通道8位无符号归一化格式读取时返回0.0~1.0的浮点值正好对应YV12每个平面每像素一个字节的布局。创建Y纹理的代码大致如下D3D11_TEXTURE2D_DESC desc {}; desc.Width width; desc.Height height; 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* texY nullptr; device-CreateTexture2D(desc, nullptr, texY);U/V纹理的宽高都除以2格式和ChromaSubsampling保持一致。D3D11_USAGE_DYNAMIC配合CPUAccessFlags说明这张纹理是要被CPU持续写入的适合每帧都有新数据进来的场景。3.2 帧数据上传Map/Unmap还是UpdateSubresource每帧上传平面数据有两种主流方式我用下来各有适用场景方式优点缺点适用场景Map memcpy可以只锁定并写入局部区域需要自己处理行对齐小尺寸帧、区域更新UpdateSubresourceAPI内部处理拷贝逻辑全量更新时开销略高全帧更新、驱动优化较好我做1080p以上分辨率时更倾向于Map memcpy因为可以精确控制每行的拷贝长度绕开解码器linesize和纹理实际宽度的差异。每次Map时使用D3D11_MAP_WRITE_DISCARD标记告诉驱动整个内容都要重写这样驱动不会尝试保留旧数据性能更好。关键代码如下D3D11_MAPPED_SUBRESOURCE mapped; deviceContext-Map(texY, 0, D3D11_MAP_WRITE_DISCARD, 0, mapped); for (int row 0; row height; row) { memcpy((BYTE*)mapped.pData row * mapped.RowPitch, srcY row * srcLinesize, width); } deviceContext-Unmap(texY, 0);U/V平面拷贝时只要把宽高换成width/2和height/2就行。这里最容易被忽略的就是mapped.RowPitch它不一定是width驱动可能会按64字节对齐直接用width去算行偏移必出错。3.3 HLSL像素着色器像素着色器的任务是对每个输出像素执行YUV到RGB的转换并输出到渲染目标。完整代码可以精简成这样Texture2D texY : register(t0); Texture2D texU : register(t1); Texture2D texV : register(t2); SamplerState samPoint : register(s0); struct PSInput { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; }; float4 mainPS(PSInput input) : SV_TARGET { float y texY.Sample(samPoint, input.uv).r * 255.0; float u texU.Sample(samPoint, input.uv).r * 255.0 - 128.0; float v texV.Sample(samPoint, input.uv).r * 255.0 - 128.0; // BT.709 高清矩阵 float r y 1.5748 * v; float g y - 0.1873 * u - 0.4681 * v; float b y 1.8556 * u; return float4(r / 255.0, g / 255.0, b / 255.0, 1.0); }注意texU和texV也必须用像素对应的坐标采样但因为U/V纹理尺寸只有Y纹理四分之一着色器硬件会自动处理归一化采样坐标一个UV坐标在不同纹理上采样到的位置是正确对应的。3.4 顶点缓冲和DrawCall视频渲染本质上是往屏幕上画一个带纹理的矩形。// 顶点结构位置 UV struct Vertex { float x, y, z; float u, v; };用一组四顶点三角形条带即可UV范围从(0,0)到(1,1)。如果视频宽高比和窗口不一致可以在顶点坐标里就做好等比缩放也可以在顶点着色器里通过常量矩阵做裁剪。更讲究一点的做法是再给顶点着色器传一个scale系数在窗口尺寸变化时只更新常量缓冲区不需要重建顶点缓冲。每次绘制时绑定三张纹理的ShaderResourceView然后Draw(4, 0)即可。几个关键状态设置混合状态关闭混合视频是不透明的别让Alpha混合拖慢速度。深度模板状态关闭深度测试视频永远在屏幕空间顶层。光栅化状态Cull None三角形条带正反面都要显示。4. 设备丢失问题的排查与恢复4.1 从Unreal Engine的报错说起我项目上线后收到过一组崩溃日志其中大量问题集中在报错字符串是“unreal engine is exiting due to d3d device being lost”。虽然这里引擎名称是Unreal但本质上所有D3D程序都可能遇到DXGI_ERROR_DEVICE_REMOVED和DXGI_ERROR_DEVICE_RESET自定义视频渲染器也不例外。D3D设备丢失最常见的原因就是显卡驱动崩溃或者GPU在渲染过程中被系统重置例如长时间全屏渲染、驱动超时检测、显卡过热降频、断电式TDR。处理不好轻则黑屏卡死重则整个进程崩溃。Unreal选择直接退出但在视频播放终端里这样搞绝对不行。4.2 D3D9时代和D3D11时代的差异D3D9时代设备丢失是个很痛苦的事全屏模式下切换窗口、调整分辨率都容易触发。D3D9要求你监听WM_DISPLAYCHANGE手动调用Reset丢失后所有显存Resource都得重建这套流程很繁琐。D3D11时代的处理思路则更直接设备丢失后一切都要推倒重来。IDXGISwapChain::Present返回DXGI_ERROR_DEVICE_REMOVED或DXGI_ERROR_DEVICE_RESET时原来的Device、Context、SwapChain全都不可靠了最稳妥的方案就是全部释放并重建。重建后所有纹理、Shader、顶点缓冲都要重新创建所以我在项目里做了一个资源管理类把所有需要跟随设备创建的资源都集中管理收到设备丢失信号后调用OnDeviceLost再调用OnDeviceRestored。4.3 完整的设备恢复流程我的恢复流程分五步每步都有对应的状态检查Present返回值异常或者收到WM_DEVICECHANGE、WM_DISPLAYCHANGE进入设备丢失判定。调用ID3D11Device::GetDeviceRemovedReason()把返回的错误码记录到日志方便排查是驱动TDR还是交换链问题。释放设备上下文上的所有绑定资源清空PS、VS、渲染目标、深度模板缓冲等。依次释放SwapChain、RenderTargetView、所有视频纹理、Shader、InputLayout、顶点缓冲、常量缓冲。重新创建Device和SwapChain重建所有资源最后把上一帧画面重绘一下避免黑屏闪烁。一些D3D11下的恢复细节值得记下来SwapChain在创建时不要使用窗口尺寸写死要用DXGI_SWAP_CHAIN_DESC1并开启DXGI_SWAP_EFFECT_FLIP_DISCARD配合IDXGISwapChain1::ResizeBuffers处理窗口大小变化。全屏模式下设备丢失大概率是显卡TDR需要降低分辨率或者减少同时渲染路数否则重建后可能马上又丢。正确做法是写一个简单的状态机Running→Lost→Resetting→Running。在Lost状态下暂停视频帧上传只做资源重建重建完成后再恢复帧调度。这套状态机我现在还在用稳定性比之前一碰到设备丢失直接退出或者直接死循环强得多。5. 实测中踩过的坑和性能优化笔记5.1 画面色彩偏差初次跑通后画面整体偏绿或者色彩过浓多半是UV平面顺序错误。YV12和I420在逻辑上恰好U和V位置互换必须确认解码器输出的是不是真正的YV12。如果不是就在上传前做一次Swizzle或者直接交换U/V纹理的着色器注册序号。另一个坑是输入范围。视频解码器输出可能是有限范围Limited RangeY值16~235也可能是全范围Full Range0~255但很多解码器默认输出全范围而视频本身按有限范围编码。如果不做对应的范围处理画面会发灰、对比度下降。我的做法是在HLSL里把Y先减去16再乘以系数或者通过常量缓冲控制是否启用有限范围校正。5.2 多路视频并发时的纹理上传瓶颈多路视频同时渲染时最容易遇到的性能问题就是每帧Map/Unmap三张纹理导致的上传带宽饱和。1080p一路还好8路1080p同时上传就是每秒几十GB的带宽压力这会直接撞上PCIe瓶颈。我实测了两个优化手段一是把Y/U/V三张纹理合并成一张更大的纹理来放多个视频平面减少纹理切换次数二是利用纹理数组Texture Array每路视频独立一个Slice着色器里根据实例ID采样能明显降低状态切换开销。如果做16路以上的视频墙这一步几乎是必须的。5.3 解码帧的PTS和显示时间戳视频渲染和游戏渲染最大的不同在于它不只是“尽快画出来”而是要按解码时间戳控制显示时机。没有PTS管理时视频要么快进要么卡顿尤其是处理VFR可变帧率视频时。我的做法是给每一个上传到纹理的帧记录PTS在D3D渲染线程里用独占的帧队列缓存最近几帧渲染循环每次都从队列头部取一帧如果还没到显示时间就继续复用上一帧避免重复上传。这样画面节奏更平滑也不会造成纹理被多次写入同一个。5.4 翻转与宽高比D3D11的纹理坐标原点在左上角但解码器的第一行数据对应的是YUV坐标的顶部这时直接绘制画面往往是上下颠倒的。如果在顶点缓冲里把V坐标翻转成1.0 - v就能解决大多数视频源的倒置问题。但有些视频源自带翻转标志需要根据解码器的frame-data分配方向和矩阵参数动态调整不能写死。宽高比上视频源可能是4:3、16:9、2.35:1而显示区域可能是一块任意尺寸的窗口。我通过常量缓冲传入(显示区宽/显示区高) / (视频宽/视频高)的比值在顶点着色器里对x方向做缩放保证画面不变形。这个参数在窗口尺寸变化时只需要更新一次常量不用重建任何资源。5.5 再说一次设备丢失窗口切换也是导火索最后补一个容易被低估的设备丢失诱因从独占全屏切换回窗口模式或者从窗口拖到另一块不同刷新率的显示器。很多人以为设备丢失只有显卡驱动崩溃才会发生实际上刷新率不同步的跨屏拖动也经常触犯TDR。处理方法是监听WM_DISPLAYCHANGE在显示器参数变化时先缩小输出尺寸再重建SwapChain不要跑到新显示器上以后还按老分辨率硬来。我个人在实际项目里的体会是视频渲染这种长时间运行的场景稳定比花哨更重要。YV12在GPU上的转换方案说到底不难难的是把细节抠清楚UV平面的顺序、行对齐、矩阵选择、设备恢复。把这些处理到位剩下的事情就顺理成章了。如果你刚开始改造这套链路建议先把单路1080p跑通再逐步加多路每加一路都观察内存和带宽变化避免一次改造引入太多变量。本文还有配套的精品资源点击获取
返回列表