纯 C# 中的 EVR 演示者, 支持 Direct3D 视频渲染






4.96/5 (15投票s)
本文档描述了如何在.NET中通过Direct3D自定义演示器在EVR上使用纯C#渲染视频。
引言
这是我第二篇关于使用.NET和纯C#代码自定义视频输出的文章。很多内容与我之前的帖子相似,所以请回顾一下,因为我不会描述常见内容。我认为本教程适合高级开发人员。
开始之前
首先,您应该阅读关于如何在MSDN上构建EVR Presenter的文章,特别是先决条件部分。
多媒体线程 vs .NET线程
是的,我们应该理解这些东西,因为.NET线程完全不同。MSDN描述很好,但不够。
.NET线程和对象都在指定的执行上下文中工作和存在。某些对象不能从不同的线程访问,例如窗体和控件。其他对象可以从另一个线程访问,但这需要.NET在执行上下文之间切换(对象创建的上下文和访问的上下文)。该操作可能需要一段时间,甚至可能导致执行挂起。挂起可能由于不同的线程模型而出现。在我们的例子中,所有多媒体线程都在相同的上下文中工作,因此您可以毫无问题地访问在其他线程中创建的对象,只需不要忘记同步对象。希望我能清楚地说明差异,我们可以继续了。
跟踪、调试和异常
在回顾代码之前,我想说几句关于这些。由于不同的线程机制,我不建议您在任何处理多媒体数据或频繁访问非托管资源的代码中使用诸如 Trace.Write 或 Debug.Write 之类的东西。这还与切换线程上下文的问题有关,并导致性能下降。在同样的问题中,不建议抛出异常,因此最好检查返回值,也建议使用try catch语句。解决跟踪输出的方法是使用 OutputDebugString API。
public static void TRACE(string _message)
{
if (!string.IsNullOrEmpty(_message)) _message += "\n";
API.OutputDebugString(_message);
}
另一个与上述功能一起使用的有用辅助函数
public static void TRACE_ENTER()
{
MethodBase _method = (new StackTrace(1,false)).GetFrame(0).GetMethod();
TRACE(string.Format("{0}::{1}", _method.ReflectedType.Name, _method.Name));
}
此函数将调用者类名和方法名打印到输出窗口。
应用程序概述
演示应用程序展示了如何使用DirectShow与增强视频渲染器(EVR)和自定义演示器执行视频播放。演示器负责分配播放表面、执行媒体类型协商、表面时间戳同步以及使用Direct3D9向用户显示帧。表面呈现使用SlimDX(Direct3D的托管库)完成,类似于我的上一篇文章。
实现场景呈现
场景的呈现方式与此处类似。
过滤器图
过滤器图略有不同,但它也是播放应用程序的特定图,只是它使用了增强型视频渲染器而不是默认渲染器,看起来像这样
类声明与初始化
同样地,我使用我的类来构建图形,所以播放类的声明看起来像
public class DSFilePlaybackEVR : DSFilePlayback
, IMFVideoDeviceID
, IMFVideoPresenter
, IMFGetService
, IMFTopologyServiceLookupClient
这里,自定义EVR演示器需要继承的接口。我们还在类中有一个事件委托和事件变量,用于通知场景表面已准备好显示。EVR过滤器的声明是
[Guid("FA10746C-9B63-4b6c-BC49-FC300EA5F256")]
public class EVRRenderer : DSFilter
{
public EVRRenderer()
: base()
{
}
}
EVR类继承自基础图形构建器类,该类处理通过DirectShow播放的所有基本功能,我们只需要重写初始化过滤器和连接它们的方法
protected override HRESULT OnInitInterfaces()
{
m_evStop.Reset();
HRESULT hr;
hr = (HRESULT)MFHelper.DXVA2CreateDirect3DDeviceManager9(out m_DeviceResetToken, out m_DeviceManager);
hr.Assert();
if (hr.Succeeded)
{
hr = (HRESULT)m_DeviceManager.ResetDevice(Marshal.GetObjectForIUnknown(m_Device.ComPointer), m_DeviceResetToken);
hr.Assert();
}
m_Caller = new Wrapper(this);
m_Renderer = new EVRRenderer();
IMFVideoRenderer _renderer = (IMFVideoRenderer)m_Renderer.QueryInterface(typeof(IMFVideoRenderer));
hr = (HRESULT)_renderer.InitializeRenderer(null, (IMFVideoPresenter)this);
hr.Assert();
m_Renderer.FilterGraph = m_GraphBuilder;
hr = base.OnInitInterfaces();
hr.Assert();
return hr;
}
代码相当简单:我们初始化DXVA2设备管理器,创建EVR过滤器并将其放入图形中。在初始化EVR过滤器后,使用我们的Presenter。
实现演示器接口
现在是最难的部分,你将知道我为什么描述关于线程的事情。
调用者
我的类为EVR演示器提供的一些接口方法是从不同的线程调用的,好像只有一个线程,我们就不会有任何问题。但至少有2个,通常是3个:用户交互(播放、暂停、停止)、媒体流(样本传输)和时钟(样本同步)。在相同上下文中调用的接口是IMFVideoDeviceID和IMFGetService。对于其他接口,我们应该做一些事情才能使其正常工作,如何做呢?答案很简单:让它们在同一个线程中调用,这样上下文将是相同的。希望你对线程和同步足够了解以理解以下代码。让我们看看IMFTopologyServiceLookupClient是如何实现的
public int InitServicePointers(IntPtr pLookup)
{
Wrapper.CCallbackHandler _handler =
new Wrapper.InitServicePointersHandler(m_Caller, pLookup);
_handler.Invoke();
return _handler.m_Result;
}
public int ReleaseServicePointers()
{
Wrapper.CCallbackHandler _handler =
new Wrapper.ReleaseServicePointersHandler(m_Caller);
_handler.Invoke();
return _handler.m_Result;
}
这里我们简化地创建了指定的预定义异步调用者,等待其结果并返回。调用者回调基类如下所示
public class CCallbackHandler
{
public bool m_bAsync = false;
public CallType m_Type = CallType.Unknown;
public EventWaitHandle m_Notify = new ManualResetEvent(false);
public int m_Result = S_OK;
private Wrapper m_Invoker = null;
#region Constructor
public CCallbackHandler(Wrapper _Invoker)
{
m_Invoker = _Invoker;
}
#endregion
#region Methods
public void Invoke()
{
m_Invoker.InvokeThread(this);
WaitHandle.WaitAny(new WaitHandle[] { this.m_Notify, m_Invoker.m_Quit });
}
#endregion
}
以及实际调用者线程方法
private void InvokeThread(object _param)
{
lock (m_LockThread)
{
m_Parameter = _param;
m_Notify.Set();
}
WaitHandle.WaitAny(new WaitHandle[] { m_Quit, m_Ready });
}
private void ThreadProc(object _state)
{
while (true)
{
int nWait = WaitHandle.WaitAny(new WaitHandle[] { m_Quit, m_Notify });
if (nWait == 1)
{
object _param;
lock (m_LockThread)
{
_param = m_Parameter;
}
m_Ready.Set();
AsyncInvokerProc(_param);
}
else
{
break;
}
}
}
我们将调用者对象作为参数,然后设置通知事件,该事件唤醒线程来处理参数,并等待直到参数被检索。线程获取参数并执行传递的回调;因此对托管资源的所有访问都来自单个线程。
高级封送处理
希望你还在;因为前一部分不是最后一块硬代码。一旦方法与调用者在同一线程中被调用,我们就可以毫无问题地在类中持有COM接口。但是,任何尝试在.NET中制作EVR演示器的人,我确信都会遇到IMFTopologyServiceLookupClient接口的 InitServicePointers 方法的问题,对吗?我记得(我一两年前写的代码)问题出现在从pLookUp查询 IMFTopologyServiceLookUp 时。问题发生是因为COM具有聚合,返回的接口可能不是实际对象的接口,而.NET不处理这种情况,它只是调用QueryInterface并在不存在时失败,但我们知道它在这里。为了解决这个问题,我们只需访问我们感兴趣的接口的vtable(虚方法表)即可。该表中的所有条目都是函数指针,按照接口继承和接口方法的顺序(实际上接口是一个具有指定方法条目且别无其他的结构)。例如,接口中的前3个条目始终是IUnknown的实现,顺序为QueryInterface、AddRef和Release。这对于所有托管接口也是如此,也适用于托管对象,因为所有托管对象默认都是COM对象(这只是对开发人员隐藏了)。所以我制作了一个辅助类,允许通过vtable访问COM对象
public class VTableInterface : COMHelper,IDisposable
{
#region Delegates
private delegate int QueryInterfaceProc(
IntPtr pUnk,
ref Guid riid,
out IntPtr ppvObject
);
#endregion
#region Variables
protected IntPtr m_pUnknown = IntPtr.Zero;
#endregion
#region Constructor
protected VTableInterface(IntPtr pUnknown)
{
if (pUnknown != IntPtr.Zero)
{
m_pUnknown = pUnknown;
Marshal.AddRef(m_pUnknown);
}
}
~VTableInterface()
{
Dispose();
}
#endregion
#region Methods
public int QueryInterface(ref Guid riid, out IntPtr ppvObject)
{
ppvObject = IntPtr.Zero;
if (m_pUnknown == IntPtr.Zero) return E_NOINTERFACE;
QueryInterfaceProc _Proc = GetProcDelegate<QueryInterfaceProc>(0);
if (_Proc == null) return E_UNEXPECTED;
return (HRESULT)_Proc(m_pUnknown,ref riid,out ppvObject);
}
#endregion
#region Helper Methods
protected T GetProcDelegate<T>(int nIndex) where T : class
{
IntPtr pVtable = Marshal.ReadIntPtr(m_pUnknown);
IntPtr pFunc = Marshal.ReadIntPtr(pVtable, nIndex * IntPtr.Size);
return (Marshal.GetDelegateForFunctionPointer(pFunc, typeof(T))) as T;
}
#endregion
#region IDisposable Members
public void Dispose()
{
if (m_pUnknown != IntPtr.Zero)
{
Marshal.Release(m_pUnknown);
m_pUnknown = IntPtr.Zero;
}
}
#endregion
}
这里主要有趣的方法是 GetProcDelegate
,它允许通过索引从 vtable 获取方法。它是如何工作的,您可以在 QueryInterface 实现中看到。因此,为了毫无问题地实现 IMFTopologyServiceLookUp,我们编写以下代码
public class MFTopologyServiceLookup : VTableInterface, IMFTopologyServiceLookup
…
private delegate int LookupServiceProc(
IntPtr pUnk,
MFServiceLookUpType Type,
uint dwIndex,
[In, MarshalAs(UnmanagedType.LPStruct)] Guid guidService,
[In, MarshalAs(UnmanagedType.LPStruct)] Guid riid,
[Out, MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.SysInt)] IntPtr[] ppvObjects,
[In, Out] ref uint pnObjects);
….
public int LookupService(MFServiceLookUpType _type, uint dwIndex, Guid guidService, Guid riid, IntPtr[] ppvObjects, ref uint pnObjects)
{
if (m_pUnknown == IntPtr.Zero) return E_NOINTERFACE;
LookupServiceProc _lookUpProc = GetProcDelegate<LookupServiceProc>(3);
if (_lookUpProc == null) return E_UNEXPECTED;
return (HRESULT)_lookUpProc(
m_pUnknown,
_type,
dwIndex,
guidService,
riid,
ppvObjects,
ref pnObjects
);
}
我觉得没那么难。忘记提及接口委托函数与接口中的方法声明在一个额外参数上有所不同。第一个参数应该是 vtable 对象的指针或我们的 IntPtr,为什么这是必要的,我想你可以在网上找到。
样本调度器和空闲样本通知
如果您查看Microsoft的EVR Presenter C++示例,您会发现它在释放时定义了空闲样本,我的意思是调用了Release,并设置了指定的通知。NET不允许我们使用该方法,因为我们无法直接访问IUnknown。所以我通过使用事件解决了这个问题(对于某些人来说,使用信号量可能更好,但这只是一个示例)。
主应用程序
主窗体的实现非常简单,这里我描述了最有趣的方法。场景和播放的变量声明
private Scene m_Scene = null;
private DSFilePlayback m_Playback = null;
创建场景对象
m_Scene = new Scene(this.pbView);
这里 pbView
是我们将要呈现视频的控件。开始播放代码
m_Playback = new DSFilePlaybackEVR(m_Scene.Direct3DDevice);
m_Playback.OnPlaybackStop += new EventHandler(btnStart_Click);
((DSFilePlaybackEVR)m_Playback).OnSurfaceReady += new EVR.SurfaceReadyHandler(m_Scene.OnSurfaceReady);
m_Playback.FileName = this.tbFileName.Text;
if (m_Playback.Start().Succeeded)
{
btnStart.Text = "Stop";
btnBrowse.Enabled = false;
}
在这段代码中,我们创建了一个具有指定场景设备的EVR渲染图。之后,我们为表面就绪通知提供事件处理程序,并开始播放。停止代码相当简单——只需处置播放
m_Playback.Dispose();
m_Playback = null;
历史
初始版本 2012年7月11日