ARTICLE DETAIL

资讯详情

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

WPF D3D demo:NV12 YUV帧硬件加速送入D3DImage

WPF D3D demo:NV12 YUV帧硬件加速送入D3DImage 简介面向WPF桌面开发者的一手D3D视频渲染示例演示在WPF框架中借助Direct3D硬件加速呈现YUV视频流弥补WPF原生控件对YUV格式支持不足、软件渲染开销高的短板适合做高性能播放器或实时视频展示的.NET开发者参考。压缩包共43个文件核心为22个cs源代码文件涵盖D3DElement渲染类、FrameData帧数据封装、D3DImageSource与WriteableBitmapSource等WPF对接逻辑另有8个dll运行库包含SlimDX及FFmpeg解码相关动态库辅助2个xaml界面布局和解决方案文件整体约22.76MB。已有1025人学习。附带yv12.dat原始YUV视频帧数据与完整工程结构可以逐步查看从后台线程读取YUV、创建D3D纹理到输出到WPF可视对象的完整链路并理解YUV的Y/UV分量构成、与RGB的换算关系以及通过硬件加速保持UI线程顺畅的整体设计思路。1. WPF D3D demo把 YUV 帧送进 D3DImage 的完整落地路线WPF 开发者迟早会遇到一个尴尬场景解码器给的是 NV12 或 YUV420P 帧而 WPF 的 Image 只认 BitmapSource 这类 BGRA 数据。你要是用 WriteableBitmap 一块块做像素拷贝1080p 视频能直接把 UI 线程压到 20 FPS 以下拖动窗口都像在翻幻灯片。这就说明 WPF 的 2D 渲染管线和视频帧之间缺一个硬件加速的中间层。这份 WPF D3D demo 要解决的就是这个问题它把 Direct3D9 和 WPF 的 D3DImage 相互操作完整跑通了一遍从 NV12 纹理创建、像素着色器颜色转换、渲染循环到 DirtyRect 刷新都有现成代码可抄。适合做视频监控画面、摄像头预览、流媒体渲染的 WPF 工程新手能拿它当 D3DImage 的入门参考熟手可以在它基础上直接替换颜色矩阵和帧源。2. YUV 到 D3D 纹理NV12 拆解、颜色矩阵与像素着色器2.1 为什么选 NV12解码器的输出格式决定渲染链路视频解码器很少直接给你 BGRA 帧。H.264/HEVC 解码后最常见的输出就是 NV12尤其在使用 DXVA2、NVDEC 或 FFmpeg 的 sws 转换之前原生帧就是 YUV 格式。如果你把 YUV 在 CPU 侧转成 BGRA 再塞给 WPF等于每次都做一次全图颜色空间转换这个计算量在 4K 视频上非常可观。我一般会把 YUV 转换放到 GPU 侧把 NV12 的 Y 平面和 UV 平面分别做成 D3D9 纹理再用像素着色器在渲染到后台缓冲的同时完成 YUV 到 RGB 的转换。这样 CPU 只负责把解码帧内存拷贝到纹理颜色转换交给 GPUUI 线程的压力会小很多。选择 D3D9 而不是 D3D11原因很简单WPF 的 D3DImage 官方只直接支持 D3D9 交换链D3D11 做互操作要绕一层 WARP 或者自家封装demo 复杂度会高不少。2.2 NV12 的内存布局Y 与 UV 分开放逐行拷贝避免斜纹NV12 在内存里分两段先是 Y 平面大小是width × height每个像素一个字节后面跟着 UV 平面宽是width / 2高是height / 2但每行存储的是交错排列的 U 和 V 字节也就是对每个 2×2 的 Y 块共享一对 U/V。所以 UV 平面的总字节数是width * height / 2。直接从解码器拿到的帧行步长往往不等于视频宽度。FFmpeg 解码出来的帧Y plane 的 stride 通常按 32 或 64 字节对齐如果你用一个Marshal.Copy把整块width * height拷贝到纹理就会出现经典的斜条纹。正确的做法是逐行拷贝只拷贝实际宽度步长分别取源 stride 和纹理 pitch。public void UploadNV12(byte[] frame, int width, int height, int srcYPitch) { // NV12 的 UV plane 地址紧跟在 Y plane 后面 int uvPlaneOffset srcYPitch * height; int uvHeight height / 2; // Y plane逐行拷到纹理宽度按视频实际宽度 var rectY _textureY.LockRectangle(0, LockFlags.None); for (int row 0; row height; row) { Utilities.CopyMemory( rectY.DataPointer row * rectY.Pitch, framePtr row * srcYPitch, width); } _textureY.UnlockRectangle(0); // UV plane同样是逐行拷每行宽等于视频宽度 var rectUV _textureUV.LockRectangle(0, LockFlags.None); for (int row 0; row uvHeight; row) { Utilities.CopyMemory( rectUV.DataPointer row * rectUV.Pitch, framePtr uvPlaneOffset row * srcYPitch, width); } _textureUV.UnlockRectangle(0); }这段代码里_textureY是 D3D9 的Format.L8单通道纹理_textureUV是Format.R8G8双通道纹理。LockRectangle返回的DataRectangle里包含DataPointer和Pitch前者是 GPU 可写入的内存指针后者是纹理的实际行步长必须用它作为目标地址的增量。有几个参数要尤其注意srcYPitch必须从解码器帧的Stride字段拿不能想当然等于widthframePtr是用Marshal.AllocHGlobal固定住的帧数据首地址。每次上传前别忘了先Marshal.Copy(frame, 0, framePtr, frame.Length)或者用fixed关键字取指针否则数据源不固定GPU 拷贝时极容易出现随机花屏。2.3 像素着色器BT.601 矩阵和 Full Range 的坑YUV 到 RGB 的颜色矩阵不能拍脑袋写死。标清内容常用 BT.601高清 720P/1080P 常用 BT.709二者在色域和偏移量上有差异。更坑的是 Limited Range 和 Full Range 的区别Limited Range 里 Y 值范围是 16235UV 是 16240Full Range 里 Y 和 UV 都占满 0255。很多解码器默认输出 Full Range而 shader 代码照抄网络上的 BT.601 Limited Range结果就是画面灰蒙蒙、对比度不对。sampler2D texY : register(s0); sampler2D texUV : register(s1); float4 PS(float2 uv : TEXCOORD0) : COLOR0 { float y tex2D(texY, uv).r; float2 uvs float2(uv.x, uv.y * 0.5); float2 uvVal tex2D(texUV, uvs).rg; float y0 y - 16.0 / 255.0; float u0 uvVal.r - 128.0 / 255.0; float v0 uvVal.g - 128.0 / 255.0; float r 1.164 * y0 1.596 * v0; float g 1.164 * y0 - 0.391 * u0 - 0.813 * v0; float b 1.164 * y0 2.018 * u0; return saturate(float4(r, g, b, 1.0)); }这段 shader 针对 Limited Range BT.601uv.y * 0.5是因为 UV 纹理的高度只有 Y 纹理的一半采样时要垂直压缩。如果你确认源帧是 Full Range就把y - 16/255改成yuv - 128/255改成uv - 0.5再把 1.164 换成 1.0其他系数不变。BT.709 的系数是另一组R Y 1.540 * VG Y - 0.459 * U - 0.812 * VB Y 1.810 * U别混用。创建纹理时的参数也要跟着 shader 走Y 纹理用Format.L8UV 纹理用Format.R8G8。不要把 UV 纹理创建成Format.A8R8G8B8再去取红色和绿色分量那样采样坐标会多一层偏移。sampler 最好用 Point 采样否则 YUV 在边界处被线性过滤后画面边缘会有一圈彩色镶边。3. demo 工程骨架D3D9 设备创建与渲染循环设计3.1 D3D9 设备与 D3DImage 的挂钩顺序D3DImage 要正常工作D3D9 设备必须带Multithreaded标志否则在非创建线程调用渲染接口会直接抛异常。设备参数里的DeviceWindowHandle可以用窗口句柄也可以用IntPtr.Zero但Windowed必须为true。D3DImage 的 SetBackBuffer 受限于 D3D9 的纹理规格后台缓冲纹理尺寸必须和 D3DImage 的像素尺寸一致否则会出现黑屏或撕裂。private Texture _renderTarget; public D3DImage CreateAndBindD3DImage(IntPtr windowHandle, int videoWidth, int videoHeight) { var d3d9 new Direct3DEx(); var pp new PresentParameters { Windowed true, SwapEffect SwapEffect.Discard, DeviceWindowHandle windowHandle, PresentationInterval PresentInterval.Default }; _device new DeviceEx( d3d9, 0, DeviceType.Hardware, windowHandle, CreateFlags.HardwareVertexProcessing | CreateFlags.Multithreaded | CreateFlags.FpuPreserve, pp); _renderTarget new Texture( _device, videoWidth, videoHeight, 1, Usage.RenderTarget, Format.A8R8G8B8, Pool.Default); var d3dImage new D3DImage(); d3dImage.Width videoWidth; d3dImage.Height videoHeight; using (var surface _renderTarget.GetSurfaceLevel(0)) { d3dImage.Lock(); d3dImage.SetBackBuffer(D3DResourceType.IDirect3DSurface9, surface.NativePointer); d3dImage.Unlock(); } return d3dImage; }这里_renderTarget必须声明成类字段因为它的生命周期要和 D3DImage 一致。Using块里GetSurfaceLevel返回的 Surface 是临时对象但 SetBackBuffer 只是拿走了底层指针不会增加引用计数所以用完后释放临时 Surface 是安全的。FpuPreserve标志看着冷门实际很重要WPF 内部会修改浮点控制字不加上它D3D9 的数学计算偶尔会出现细微误差在高频渲染时表现为顶点坐标轻微抖动。创建完 D3DImage 后把它赋给任意Image控件的Source就行。注意不要在 XAML 里给 Image 设StretchFill之外的模式否则 D3DImage 的像素长宽比会被扭曲。3.2 渲染循环选型DispatcherTimer 还是 CompositionTarget.Renderingdemo 里最常见的两个渲染驱动方式是DispatcherTimer和CompositionTarget.Rendering。我一开始图省事用了DispatcherTimer间隔设 33ms结果发现两个问题一是 Timer 受 UI 线程消息泵影响帧间隔不稳定二是如果 WPF 窗口内部没有其他失效区域D3DImage 的刷新不会和 WPF 的合成节奏对齐导致画面时快时慢。换成CompositionTarget.Rendering后渲染回调会和 WPF 的绘制线程同步每次 WPF 合成画面时执行一次帧上传。这个事件的触发依赖窗口是否有内容失效所以每次渲染后要主动调AddDirtyRect标记区域否则回调不会持续触发。public void StartRendering() { CompositionTarget.Rendering OnRendering; } private void OnRendering(object sender, EventArgs e) { if (!_frameAvailable) return; UploadFrame(_pendingFrame, _frameWidth, _frameHeight, _frameStride); _d3dImage.Lock(); _d3dImage.AddDirtyRect(new Int32Rect(0, 0, _frameWidth, _frameHeight)); _d3dImage.Unlock(); _frameAvailable false; }_frameAvailable这个布尔标志是必要的。解码线程拿到新帧后只做浅拷贝并置位不在解码线程里直接调 D3D 接口全部 D3D 操作都留在 UI 线程的 Rendering 回调里执行。如果你解码回调里直接 Update 纹理就要给所有 D3D 调用加锁否则CompositionTarget.Rendering还没执行到纹理已经被下一帧覆盖画面会产生撕裂。3.3 渲染一帧的完整链路Clear、DrawPrimitives、EndSceneD3DImage 后台缓冲本质上是一张 RenderTarget 纹理。每次渲染前先把目标纹理设成设备的 RenderTarget清空后画一个全屏四边形像素着色器在四边形片元阶段完成 YUV 到 RGB 转换最后EndScene结束渲染。public void RenderFrame() { using (var surface _renderTarget.GetSurfaceLevel(0)) { _device.SetRenderTarget(0, surface); } _device.Clear(ClearFlags.Target, new Color4(0, 0, 0, 0), 0, 0); _device.BeginScene(); _device.SetTexture(0, _textureY); _device.SetTexture(1, _textureUV); _device.SetPixelShader(_pixelShader); _device.DrawPrimitives(PrimitiveType.TriangleStrip, 0, 2); _device.EndScene(); }四边形顶点缓冲要在初始化时创建一次别每帧都新建。直接用TriangleStrip画两个三角形四个顶点覆盖整个 RenderTarget顶点坐标用投影矩阵映射。shader 里采样 Y 纹理和 UV 纹理时纹理坐标直接取顶点坐标的 xy这样能保证像素和纹理一一对应。要注意SetRenderTarget参数是Surface每次从_renderTarget.GetSurfaceLevel(0)拿。如果同时开了多个 RenderTarget用完后要SetRenderTarget(0, null)解除绑定否则释放 Surface 时可能失败。demo 里只有一层后台缓冲不需要额外处理。4. WPF D3D 渲染 YUV 的避坑记录五个翻车现场与排查路径4.1 窗口打开后全黑SetBackBuffer 时机与尺寸对不上现象程序能启动D3DImage 控件位置也有内容占位但画面完全黑掉没有报错。原因最常见的是没有在Lock和Unlock之间调用SetBackBuffer。D3DImage 对后台缓冲的挂接有严格状态要求必须在锁定状态下替换。另一个高频原因是后台缓冲纹理尺寸和 D3DImage 的 Width/Height 不一致D3D9 在 SetBackBuffer 时不会帮你做缩放。解决确认_renderTarget的宽高等于videoWidth/videoHeightD3DImage 的 Width/Height 也设置为同样的值。设置顺序必须是创建纹理 → 创建 D3DImage → Lock → SetBackBuffer → Unlock。如果设备丢失重建了渲染目标也要走同样的顺序缺一不可。4.2 画面上出现斜条纹或彩色噪点帧步长与纹理 Pitch 不一致现象视频能显示但画面像被斜着切了一刀或者有规律性的彩色干扰条纹。原因我写过一次偷懒代码直接按width * height做了一次CopyMemory完全忽略了解码器的Stride。当源帧步长大于视频宽度时第二行数据起点比预期偏移了几个字节导致整张图出现斜切效果。解决所有帧上传都走逐行拷贝目标偏移用纹理的Pitch源偏移用解码器的Stride。严格来说源 Y 平面和 UV 平面的 Stride 可能不同保险做法是分别从解码器帧的Data[0]和Data[1]拿各自的 Stride不要默认相等。4.3 窗口最小化再恢复后只剩桌面残影D3D9 设备丢失没处理现象最小化窗口超过十几秒恢复后画面是冻结的桌面背景残留或者干脆变黑。原因D3D9 的设备在独占模式下会因失去焦点或窗口大小变化而变成 Lost 状态尤其是Windowed true但交换链创建方式不合适时。很多人以为 Windowed 就不会丢设备实际在显卡驱动重置、节能切换时照样会丢。解决渲染循环里检查_device.TestCooperativeLevel()返回Lost时停止渲染返回DeviceNotReset时先Reset再重建 RenderTarget最后重新 SetBackBuffer。代码结构如下var result _device.TestCooperativeLevel(); if (result ResultCode.DeviceLost) return; if (result ResultCode.DeviceNotReset) { using (var surface _renderTarget.GetSurfaceLevel(0)) _device.SetRenderTarget(0, null); _renderTarget.Dispose(); _device.Reset(new PresentParameters { Windowed true, SwapEffect SwapEffect.Discard, PresentationInterval PresentInterval.Default }); _renderTarget CreateRenderTarget(_device); RebindBackBuffer(); }4.4 解码线程直接调 D3D 导致 UI 卡死缺少跨线程同步现象程序跑 5 分钟就卡死断点发现停在 D3D 纹理上传的地方主线程同时也在等 D3D 接口返回。原因我在 demo 初期图省事把UploadFrame直接放进了解码线程。D3D9 虽然创建时加了Multithreaded但多个线程同时调用设备指令时内部锁竞争非常激烈。尤其解码帧率 60FPS 时解码线程和 UI 渲染线程互相抢设备锁最终死锁。解决解码线程只生产数据把帧拷贝到托管缓冲区并挂到队列里标记_frameAvailable true。UI 线程的CompositionTarget.Rendering回调里再取队列数据上传纹理。如果队列有积压可以丢帧优先显示最新帧void OnRendering(object sender, EventArgs e) { if (_frameQueue.TryDequeue(out var frame)) { UploadNV12(frame.Data, frame.Width, frame.Height, frame.Stride); _d3dImage.Lock(); _d3dImage.AddDirtyRect(new Int32Rect(0, 0, frame.Width, frame.Height)); _d3dImage.Unlock(); } }4.5 内存占用持续上涨Surface 和 Shader 资源没有释放现象任务管理器里进程内存每分钟涨几十 MB显卡内存也同步增长。原因最常见的是每帧都调用new Texture或者GetSurfaceLevel后不释放。另一个隐蔽点是把 pixel shader 的编译结果缓存在局部变量里忘记Disposeshader 资源在 D3D9 设备里会累积。解决纹理、顶点缓冲、shader 都在初始化阶段创建一次只在设备重置时重建。GetSurfaceLevel返回的 Surface 是临时引用用using包裹或在用完后Dispose。如果是CompositionTarget.Rendering里每帧都BeginScene确保顶点缓冲绑定后也调用SetStreamSource(0, null, 0, 0)解除引用避免 GPU 资源生命周期被意外拉长。5. 性能验证FPS 统计与一段可复用的渲染循环模板5.1 用 Stopwatch 做简易 FPS 统计验证渲染性能最直接的办法是数渲染回调的触发次数。不要在解码线程统计那里只能说明解码性能不能说明 UI 渲染链路是否顺畅。我在 UI 线程渲染回调里累计帧数每 120 帧打印一次平均帧间隔private int _frameCount; private TimeSpan _lastCheckTime; void OnRendering(object sender, EventArgs e) { var now Stopwatch.GetTimestamp(); _frameCount; if (_frameCount 120) { var elapsedTicks now - _lastTicks; var fps _frameCount / (elapsedTicks / (double)Stopwatch.Frequency); Debug.WriteLine($D3D render FPS: {fps:F1}); _frameCount 0; _lastTicks now; } // 帧上传和 DirtyRect 刷新逻辑 }如果 FPS 稳定在显示器的刷新率附近比如 60 或 120说明渲染链路已经被垂直同步钳住如果 FPS 低于 30先看每帧是否有重复上传、是否有不必要的 D3D 状态切换。BeginScene/EndScene里不要加Clear和绘制之外的逻辑比如字符串格式化或日志输出都会偷偷吃掉帧预算。5.2 一段适合直接改的渲染循环模板以下是我现在每次做 WPF 视频渲染都会用的基础模板。它把「是否有新帧」和「实际渲染」分开解码线程只负责置位渲染线程负责上传和刷新。窗口关闭时记得把CompositionTarget.Rendering的委托解绑否则窗口被 GC 释放后事件还会触发导致访问已释放对象。public void Start() { CompositionTarget.Rendering OnRendering; } private void OnRendering(object sender, EventArgs e) { if (_disposed) return; if (!_frameAvailable) return; UploadNV12(_frameData, _videoWidth, _videoHeight, _frameStride); RenderFrame(); _d3dImage.Lock(); _d3dImage.AddDirtyRect(new Int32Rect(0, 0, _videoWidth, _videoHeight)); _d3dImage.Unlock(); _frameAvailable false; } public void Stop() { CompositionTarget.Rendering - OnRendering; }这个模板的好处是渲染逻辑只会在有新帧时执行静止画面不会空转。AddDirtyRect的矩形一定要用视频原始分辨率如果 D3DImage 和视频尺寸一致直接用0,0,width,height即可如果 D3DImage 做了缩放DirtyRect 也要按缩放后的尺寸填。从那以后我每次写 WPF 视频渲染都会强制先确认三件事帧源步长、D3DImage 尺寸、设备丢失处理这三处不出问题整套链路基本就稳了。希望这份 D3D demo 的拆解笔记能帮你在做摄像头预览或视频流渲染时少走几个弯路。本文还有配套的精品资源点击获取
返回列表