SteamVR(HTC Vive) Unity插件深度分析(二)
2. openvr_api.cs
位于Plugins/openvr_api.cs。这个文件基本上是对C++ SDK中的openvr.h的C#翻译(注:从Unity Asset Store里面下载的应该是最新稳定版,但在github上master分支上才是最新的,github上Unity插件位于unity_package目录下),而且这个翻译是自动完成的,在openvr_api.cs文件的最开头有注释说这个文件是自动生成的。
首先也有对openvr_api.dll中导出函数的定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public class OpenVRInterop { [DllImportAttribute( "openvr_api" , EntryPoint = "VR_InitInternal" )] internal static extern uint InitInternal( ref EVRInitError peError, EVRApplicationType eApplicationType); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_ShutdownInternal" )] internal static extern void ShutdownInternal(); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_IsHmdPresent" )] internal static extern bool IsHmdPresent(); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_IsRuntimeInstalled" )] internal static extern bool IsRuntimeInstalled(); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_GetStringForHmdError" )] internal static extern IntPtr GetStringForHmdError(EVRInitError error); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_GetGenericInterface" )] internal static extern IntPtr GetGenericInterface([In, MarshalAs(UnmanagedType.LPStr)] string pchInterfaceVersion, ref EVRInitError peError); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_IsInterfaceVersionValid" )] internal static extern bool IsInterfaceVersionValid([In, MarshalAs(UnmanagedType.LPStr)] string pchInterfaceVersion); [DllImportAttribute( "openvr_api" , EntryPoint = "VR_GetInitToken" )] internal static extern uint GetInitToken(); } |
我们再来看一下对VR接口类的定义,与C++的定义是类似的,不过,显然C#有一种机制将C++的类定义转换成C#的类定义。以IVRSystem为例,C++的定义为:
1 2 3 4 5 6 7 8 9 | class IVRSystem { public : /** Suggested size for the intermediate render target that the distortion pulls from. */ virtual void GetRecommendedRenderTargetSize( uint32_t *pnWidth, uint32_t *pnHeight ) = 0; ... } |
对应的C#定义为:
1 2 3 4 5 6 7 8 9 10 11 | [StructLayout(LayoutKind.Sequential)] public struct IVRSystem { [UnmanagedFunctionPointer(CallingConvention.StdCall)] internal delegate void _GetRecommendedRenderTargetSize( ref uint pnWidth, ref uint pnHeight); [MarshalAs(UnmanagedType.FunctionPtr)] internal _GetRecommendedRenderTargetSize GetRecommendedRenderTargetSize; ... } |
这里有一些语法点,我们先不管,我们先来探究一下,因为openvr_api.dll中导出的VR_GetGenericInterface返回的是C++的类指针,是不能直接转化为上面的C#的结构(或者类)对象的。它是如何转换的呢?我们再来看一下openvr_api.cs中的一个转换类CVRSystem:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public class CVRSystem { IVRSystem FnTable; internal CVRSystem(IntPtr pInterface) { FnTable = (IVRSystem)Marshal.PtrToStructure(pInterface, typeof (IVRSystem)); } public void GetRecommendedRenderTargetSize( ref uint pnWidth, ref uint pnHeight) { pnWidth = 0; pnHeight = 0; FnTable.GetRecommendedRenderTargetSize( ref pnWidth, ref pnHeight); } ... } |
我们再来看一下对CVRSystem的使用,在Init函数中间接调用的:
1 2 3 4 5 | var eError = EVRInitError.None; var pInterface = OpenVRInterop.GetGenericInterface(FnTable_Prefix+IVRSystem_Version, ref eError); if (pInterface != IntPtr.Zero && eError == EVRInitError.None) m_pVRSystem = new CVRSystem(pInterface); |
显然,OpenVRInterop.GetGenericInterface就是上面定义的dll中的导出函数VR_GetGenericInterface,而它的返回值是相应C++接口类的指针。可以看到把它作为参数传给了CVRSystem构造函数并创建了它。CVRSystem构造函数中最重要的是下面这句:
FnTable =(IVRSystem)Marshal.PtrToStructure(pInterface, typeof(IVRSystem));
虽然不知道Marshal.PtrToStructure实现细节,但从名字上看意思是将一个C++的指针(实际上就是C++类对象内存起始地址)转换成C#结构体。因为C++对象的内存结构实际上就是成员变量+函数指针,而由于接口类定义的全是函数,因此整个C++对象就是一个函数指针表,这也是为什么成员变量名字定义为FnTable的原因。
从上面的转换关系可知,最终对外提供接口的是CVRxxx类,它封装了所有的IVRxxx调用,IVRxxx可以将相关的C#调用转换成对C++对象函数指针(即内存地址)的调用。
在C++的版本中,对外并没有暴露VR_InitInternal及VR_ShutdownInternal这两个初始化及释放函数,而是提供了vr::VR_Init及vr::VR_Shutdown两个封装函数。在C#中,有着完全相同的实现,它提供了OpenVR.Init和OpenVR.Shutdown两个静态函数封装,实现逻辑与C++版本是一样的。这里也把代码贴出来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | // 返回值为IVRSystem接口的封装类CVRSystem对象,并通过peError引用参数返回错误代码 public static CVRSystem Init( ref EVRInitError peError, EVRApplicationType eApplicationType = EVRApplicationType.VRApplication_Scene) { // 先调用内部的InitInternal来做实际的初始化。OpenVR.InitInternal是对 openvr_api.dll中的导出函数VR_InitInternal的封装 VRToken = InitInternal( ref peError, eApplicationType); //OpenVRInternal_ModuleContext中保存了所有的接口类的对象,这里先清空 OpenVRInternal_ModuleContext.Clear(); if (peError != EVRInitError.None) return null ; // 这个函数会返回IVRSystem对象,这里先判断这个接口是否有效 bool bInterfaceValid = IsInterfaceVersionValid(IVRSystem_Version); if (!bInterfaceValid) { // 如果无效,则释放VR系统 ShutdownInternal(); peError = EVRInitError.Init_InterfaceNotFound; return null ; } // 返回OpenVR.System。这是个存取属性,会通过VR_GetGenericInterface获取 IVRSystem对象 return OpenVR.System; } |
OpenVR.Shutdown就是简单地调用了VR_ShutdownInternal来实现的。
对openvr_api.cs总结一下就是:总体上它是对C++版的SDK openvr.h的完全翻版,实现上,它有对openvr_api.dll导出函数的直接封装,封装类为OpenVRInterop,然后有对VR_GetGenericInterface获取到的C++接口类对象指针的直接封装(IVRxxx系列,比如IVRSystem)和间接封装(CVRxxx系列,比如CVRSystem)。CVRxxx系列是对外提供的接口,内部通过IVRxxx将托管调用转换为C++非托管调用。需要注意的是,还有一个类OpenVR,它是对OpenVRInterop的一个封装,里面进行了一些简单的数据类型的转换,比如将字符串类型(本质上也是内存指针)转换成C#的string。同C++的实现中有一个COpenVRContext类封装了所有的VR接口对象的指针一样,C#中在OpenVR中也有一个子类COpenVRContext,它保存的就是相应的CVRxxx对象了。C++中有一个保存token的静态变量,并且通过VRToken函数来存取,当时就说了用函数来存取没有意义,还不如直接存取变量。这里C#里也有一个VRToken,不过这里就直接是静态变量了。同样,也有一个OpenVRInternal_ModuleContext来创建并返回COpenVRContext对象,在C++中它是一个存取函数,在C#里,同样变成了静态变量。另外一点要注意的是在通过GetGenericInterface获取相应的接口指针时,在C++的实现中是直接传入了“IVRSystem_001”这样的版本号,而在C#里面在版本号前面加了前缀“FnTable:”,即传入的是“FnTable:IVRSystem_001”这样的,估计是在openvr_api.dll中做了一些区分处理。
整理成表格为:
类 | 作用 |
OpenVRInterop | 对openvr_api.dll中的导出函数的直接封装 |
IVRxxx(比如IVRSystem) | 将C#的方法调用转换成对openvr_api.dll中导出的C++对象指针的调用 |
CVRxxx(比如CVRSystem) | 对外提供OpenVR核心功能的接口,Unity插件中对OpenVR核心功能折访问都是通过该系列类来访问的 |
OpenVR | 对OpenVRInterop进一步封装,并提供简单的数据类型转换。同时相当于是C++版本的vr名字空间,提供一些全局静态数据及方法,包括一些常量定义、Init/Shutdown方法,所有CVRxxx接口对象 |
COpenVRContext | 这个是OpenVR的内部类,主要提供对CVRxxx对象的保存及获取。在每次获取CVRxxx对象前都会去检查是否失效(token是否有效),如果已经失效,会重新获取 |
画成类图如下:
另外,由于Unity插件中的openvr_api.dll还导出了一些Unity前缀的显然是用于Unity的函数,这些没有在openvr_api.cs中导入,但会在其它cs文件中导入,见后面的分析。
2.1. 部分C#语法细节
对一些不太常见的C#语法做一些分析,看它是如何与C++的(非托管)dll及指针打交道的。
2.1.1. StructLayout
IVRxxx类的定义都采用的是struct定义,前面都加了StructLayout声明,比如IVRSystem的定义格式为:
1 2 3 4 5 | [StructLayout(LayoutKind.Sequential)] public struct IVRSystem { ... } |
首先类定义前的中括号[]是干嘛的?这个类似于java中的annotation。在C#里面,这个叫属性Attribute,可以用来描述程序集、类、方法、属性、事件、字段、参数、返回值等。这些描述信息可以在运行时取到,也可以在运行前检查。属性有些是系统预定义的,开发者也可以自定义,都需要从Attribute派生。
再来看StructLayout,它是CLR(Common Language Runtime)控制类或结构体的字段在托管内存中的物理布局的。这里的StructLayout(LayoutKind.Sequential)表示接下来的类或结构体定义中数据字段是按顺序存储的(这也是默认的情况)。这里之所以要使用Sequential类型的StructLayout,是因为后面会有Marshal.PtrToStructure将从非托管DLL中取到的C++的接口指针(连续内存,连续的函数指针表)转换成这里的C#结构体,要让函数一一对应,就得使用顺序存储
2.1.2. UnmanagedFunctionPointer
这个看起来是对非托管函数指针的属性进行约束的,在IVRxxx结构体定义中,大量使用的是这种:
1 2 | [UnmanagedFunctionPointer(CallingConvention.StdCall)] internal delegate void _GetRecommendedRenderTargetSize( ref uint pnWidth, ref uint pnHeight); |
从名字上看,显然是指定调用约定的(所谓调用约定是指函数参数的入栈顺序及堆栈清理的规则等)。在做Windows开发时,经常可以看到cdecl和stdcall两种调用约定。C/C++缺省是cdecl调用约定,但Windows的API都是stdcall约定,通常导出的API(dll中的导出函数)都会用stdcall约定。上面的例子中,就是指定了_GetRecommendedRenderTargetSize这个委托定义的函数调用规则为stdcall调用规则。
2.1.3. MarshalAs
这个属性用于指定托管代码与非托管代码之间数据传递的规则。准确地说应该就是非托管的数据(变量)如何赋值(转换)给非托管代码变量。Marshal这个词在COM的时候就经常见到,COM或者说任何RPC都会有这样的一个过程,那就是把数据(包括对象)序列化,然后再反序列化,这个过程就是marshal和unmarshal。比如:
1 2 | [MarshalAs(UnmanagedType.FunctionPtr)] internal _GetRecommendedRenderTargetSize GetRecommendedRenderTargetSize; |
这里定义的Marshal规则为接下来的变量定义对应于非托管(C++)数据类型FunctionPtr,即函数指针。前面说过了在CVRxxx类中使用Marshal.PtrToStructure将获得的C++对象(函数指针表)转换成了IVRxxx结构体,应该和这里为每个函数指针(函数委托)的MarshalAs定义有关。
这里也整体说明一下IVRxxx(以IVRSystem为例)结构体中的一个变量的定义:
1 2 3 4 5 | [UnmanagedFunctionPointer(CallingConvention.StdCall)] internal delegate void _GetRecommendedRenderTargetSize( ref uint pnWidth, ref uint pnHeight); [MarshalAs(UnmanagedType.FunctionPtr)] internal _GetRecommendedRenderTargetSize GetRecommendedRenderTargetSize; |
这里就是定义了一个函数委托变量GetRecommendedRenderTargetSize,然后它可以与非委托的函数指针类型互相转化。这里使用了internal关键字,表示不能被外部(程序集)调用。调用的地方位于CVRSystem.GetRecommendedRenderTargetSize:
1 2 3 4 5 6 | public voidGetRecommendedRenderTargetSize( ref uint pnWidth, ref uint pnHeight) { pnWidth= 0; pnHeight= 0; FnTable.GetRecommendedRenderTargetSize(refpnWidth, ref pnHeight); } |
其中FnTable为将C++接口指针Marshal.PtrToStructure后的IVRSystem对象:
FnTable =(IVRSystem)Marshal.PtrToStructure(pInterface, typeof(IVRSystem));
2.2. IVRxxx
再单独说一下IVRxxx结构体定义,它的作用是定义与C++的IVRxxx接口类虚函数表一一对应的方法委托变量。最重要的是通过[UnmanagedFunctionPointer(CallingConvention.StdCall)]定义了调用约定,通过[MarshalAs(UnmanagedType.FunctionPtr)]定义了Marshal规则。典型的示例:
1 2 3 4 5 6 7 8 9 10 11 | [StructLayout(LayoutKind.Sequential)] public structIVRSystem { [UnmanagedFunctionPointer(CallingConvention.StdCall)] internaldelegate void _GetRecommendedRenderTargetSize( ref uint pnWidth, ref uintpnHeight); [MarshalAs(UnmanagedType.FunctionPtr)] internal_GetRecommendedRenderTargetSize GetRecommendedRenderTargetSize; ... } |
2.3. CVRxxx
CVRxxx的作用是将从openvr_api.dll中获取到的C++接口对象指针通过Marshal.PtrToStructure转换为C#结构体IVRxxx。同时对外提供封装接口,封装了所有IVRxxx提供的功能,将所有调用转换到对IVRxxx中委托的调用,同时,需要的话做相关数据类型的转换,因为部分C++数据类型与C#数据类型不能简单的一对一转换(主要是字符串类型)。典型的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public classCVRSystem { IVRSystemFnTable; internalCVRSystem(IntPtr pInterface) { FnTable= (IVRSystem)Marshal.PtrToStructure(pInterface, typeof (IVRSystem)); } publicvoid GetRecommendedRenderTargetSize( ref uint pnWidth, ref uint pnHeight) { pnWidth= 0; pnHeight= 0; FnTable.GetRecommendedRenderTargetSize(refpnWidth, ref pnHeight); } ... } |
2.3.1. 字符串类型的转换
以C++接口IVRSystem::GetPropErrorNameFromEnum为例,它在C++中的定义为:
virtual constchar *GetPropErrorNameFromEnum( ETrackedPropertyError error ) = 0;
因为返回的是char*类型,对应的C#中的定义变成了:
internal delegateIntPtr _GetPropErrorNameFromEnum(ETrackedPropertyError error);
即返回的是int指针类型(C/C++指针类型在C#中都使用IntPtr表示),在CVRSystem::GetPropErrorNameFromEnum中就要进行转换:
1 2 3 4 5 | public stringGetPropErrorNameFromEnum(ETrackedPropertyError error) { IntPtrresult = FnTable.GetPropErrorNameFromEnum(error); return Marshal.PtrToStringAnsi(result); } |
调用Marshal.PtrToStringAnsi将IntPtr转换成了C# string。
2.4. 枚举
C#中的枚举和C++中的枚举是一样的,比如:
1 2 3 4 5 6 7 8 | public enumETrackingResult { Uninitialized= 1, Calibrating_InProgress= 100, Calibrating_OutOfRange= 101, Running_OK= 200, Running_OutOfRange= 201, } |
2.5. 结构体
C++中的结构体几乎和类是等价的,C#中也是如此。因此C#中的结构体的定义和前面C#中对类的定义是一样的,为了能和C++中的结构体能互相转换,需要使用StructLayout属性,比如:
1 2 3 4 5 6 7 | [StructLayout(LayoutKind.Sequential)] public struct VREvent_t { publicuint eventType; publicuint trackedDeviceIndex; publicfloat eventAgeSeconds; publicVREvent_Data_t data; } |
上面这种使用了Sequential属性,即字段(在内存中)是按顺序排列的,另外还有一种结构需要特殊表示,那就是C++中的union。因为C#中没有union,因此只能用struct代替,因为联合的含义就是各字段是共享内存的,因此在C#的定义中使用[StructLayout(LayoutKind.Explicit)]+FieldOffset来显示指定每个字段的位置,要达到union的效果,就必须所有字段都使用FieldOffset(0)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | [StructLayout(LayoutKind.Explicit)] public struct VREvent_Data_t { [FieldOffset(0)] public VREvent_Reserved_t reserved; [FieldOffset(0)] public VREvent_Controller_t controller; [FieldOffset(0)] public VREvent_Mouse_t mouse; [FieldOffset(0)] public VREvent_Scroll_t scroll; [FieldOffset(0)] public VREvent_Process_t process; [FieldOffset(0)] public VREvent_Notification_t notification; [FieldOffset(0)] public VREvent_Overlay_t overlay; [FieldOffset(0)] public VREvent_Status_t status; [FieldOffset(0)] public VREvent_Ipd_t ipd; [FieldOffset(0)] public VREvent_Chaperone_t chaperone; [FieldOffset(0)] public VREvent_PerformanceTest_t performanceTest; [FieldOffset(0)] public VREvent_TouchPadMove_t touchPadMove; [FieldOffset(0)] public VREvent_SeatedZeroPoseReset_t seatedZeroPoseReset; [FieldOffset(0)] public VREvent_Screenshot_t screenshot; [FieldOffset(0)] public VREvent_Keyboard_t keyboard; } |
2.6. 多维数组
由于C#中的数组与C/C++中的数组无论从本质和使用上都不太一致,C#中的数组是引用类型,需要动态new出来,因此原来C++中使用数组在C#中会直接使用N个数组元素来表示,比如,C++中的数组:
1 2 3 4 | structHmdVector3_t { floatv[3]; }; |
在C#中变成:
1 2 3 4 5 6 | [StructLayout(LayoutKind.Sequential)] public struct HmdVector3_t { publicfloat v0; //float[3] publicfloat v1; publicfloat v2; } |
2.7. 指针类型
C++中的指针类型在C#中都用IntPtr表示,比如C++中的结构体定义:
1 2 3 4 5 | struct RenderModel_TextureMap_t { uint16_tunWidth, unHeight; constuint8_t *rubTextureMapData; }; |
在C#中变成:
1 2 3 4 5 6 7 | [StructLayout(LayoutKind.Sequential)] public struct RenderModel_TextureMap_t { publicchar unWidth; publicchar unHeight; publicIntPtr rubTextureMapData; } |
2.8. OpenVR类
前面已经提到过,OpenVR类是对OpenVRInterOp类的封装,对OpenVRInterOp中直接导入的dll中的函数进行了封装,同时进行了必要的数据类型转换,比如:
1 2 3 4 | public staticstring GetStringForHmdError(EVRInitError error) { returnMarshal.PtrToStringAnsi(OpenVRInterop.GetStringForHmdError(error)); } |
然后就是将C++中在vr名字空间中的所有全局定义(变量、函数)都移到了这个OpenVR类里(大概是因为C#中并没有纯粹的全局变量与函数的概念)。另外C#里同样有一个COpenVRContext类,它也是放到OpenVR里面作子类的。OpenVR里面还有N个静态变量,并且提供了get及(或)set属性来访问。这在C++里面都是通过一个函数来实现的。比如:
static uintVRToken { get; set; }
VRToken用来记录当前接口指针的token,token变化表示接口指针失效,需要重新获取。在C++中它的定义为:
1 2 3 4 5 | inline uint32_t&VRToken() { staticuint32_t token; returntoken; } |
又比如在C++中提到了一个很奇葩的实现的OpenVRInternal_ModuleContext,实际上就是COpenVRContext类的全局对象,实现起来却很奇葩。在这里的C#里就能看到是完全正常的实现了:
1 2 3 4 5 6 7 8 9 10 11 12 | private staticCOpenVRContext _OpenVRInternal_ModuleContext = null ; staticCOpenVRContext OpenVRInternal_ModuleContext { get { if (_OpenVRInternal_ModuleContext == null ) _OpenVRInternal_ModuleContext= new COpenVRContext(); return_OpenVRInternal_ModuleContext; } } |
然后就是对所有接口对象的封装了:
1 2 3 4 5 6 7 8 9 10 | public static CVRSystem System { get { returnOpenVRInternal_ModuleContext.VRSystem(); } } public static CVRChaperone Chaperone { get { returnOpenVRInternal_ModuleContext.VRChaperone(); } } public static CVRChaperoneSetup ChaperoneSetup { get { returnOpenVRInternal_ModuleContext.VRChaperoneSetup(); } } public static CVRCompositor Compositor { get { returnOpenVRInternal_ModuleContext.VRCompositor(); } } public static CVROverlay Overlay { get { returnOpenVRInternal_ModuleContext.VROverlay(); } } public static CVRRenderModels RenderModels { get { returnOpenVRInternal_ModuleContext.VRRenderModels(); } } public static CVRApplications Applications { get { returnOpenVRInternal_ModuleContext.VRApplications(); } } public static CVRSettings Settings { get { return OpenVRInternal_ModuleContext.VRSettings();} } public static CVRExtendedDisplay ExtendedDisplay { get { returnOpenVRInternal_ModuleContext.VRExtendedDisplay(); } } public static CVRScreenshots Screenshots { get { returnOpenVRInternal_ModuleContext.VRScreenshots(); } } |
这里可以看到外部要使用OpenVR的接口的话,应该使用OpenVR类提供的接口,整个封装层次关系为(以IVRSystem为例):OpenVR->COpenVRContext->OpenVRInterop->CVRsystem->IVRSystem
最后,OpenVR里面提供了Init和Shutdown两个公开静态方法,这两个方法与C++中的VR_Init和VR_Shutdown的实现完全是一样的。
2.8.1. COpenVRContext
与C++中的COpenVRContext一样,它保存了所有的VR接口类的对象,所有的这些接口类对象一起就称为Context。里面只有一个简单的逻辑就是每次使用某个接口前先通过VRToken判断接口是否有效,如果接口无效(接口为空或者token变了都表示接口无效),则调用OpenVRInterop.GetGenericInterface重新获取。