Cardboard Unity源码分析
本文基于Cardboard(现为Daydream)Unity插件分析,下载地址:
https://github.com/googlevr/gvr-unity-sdk
最外层里的GoogleVR目录(包括GoogleVRForUnity.unitypackage)是最新版本,而在Samples/CastleDefense这个例子目录下的GoogleVR则是一个旧的版本(根据github上的tag,大概是0.8.0版本,而这个版本很可能就是Cardboard的最后一个版本)。从目录结构、包括预制体及完整性来说,这个版本是一个最稳定的版本,因此本文将对这个版本进行详细的分析。
1. 【总体架构】
先从总体上简单地对GVR架构进行分析,后面再对每个脚本进行详细分析。先看相机骨骼预制体:
基本上这个相机骨骼实现了除输入以外的VR显示相关的核心功能,简述如下:
l GvrMain上面挂有GvrViewer脚本,它是一个总管类,是整个GVR的核心
l Head代表头部,它上面有GvrHead脚本,它的主要作用是做头部跟踪
l Main Camera为主相机,理论上它没什么用,但在实际情况下它被称为单目相机,对左右眼的双目相机的渲染会造成影响,这个后面再看
l Main Camera Left/Right即分别代表左右眼的相机,上面有GvrEye脚本
l StereoRender是用于辅助渲染的,它本身没什么用,只是用于下面的PreRender/PostRender的父节点
l PreRender是一个相机,上面挂有GvrPreRender脚本,主要是借用相机的渲染流程,相机的depth设为-100,会被最先渲染,主要用于清理背景
l PostRender也是一个相机,上面挂有GvrPostRender脚本,其相机depth设为100,会被最后渲染,它的主要作用是用于对渲染后的左右眼图像做最后的后处理,也就是最核心的反畸变处理
1.1. 【初始化流程】
初始化流程主要涉及到VR参数的初始化。参数包括两部分,一部分是手机参数,一部分是镜片参数。这两个参数都在GvrProfile中处理,在Unity Editor中,这两个参数都是在Inspector中设置的,而在实际手机上运行时,手机参数是从libgvrunity.so中取出来的。初始化流程为:
GvrViewer.Awake()->GvrViewer.InitDevice()->BaseVRDevice.UpdateScreenData()
BaseVRDevice是基类,它根据不同设备创建实例对象,比如Android、iOS、Unity Editor等。
对于Unity Editor,流程是:
EditorDevice.UpdateScreenData()->GvrProfile.GetKnownProfile()->BaseVRDevice.ComputeEyesFromProfile
其中,GetKnownProfile是根据在Inspector中的选择从在代码中硬编码的参数中选择参数
对于Android设备,流程是:
GvrDevice.UpdateScreenData()->GvrDevice.UpdateProfile()->GvrDevice.GetProfile()->GvrDevice.UpdateView->GvrDevice.GetViewParameters
其中,GetProfile和GetViewParameters是从so中导入的方法,也就是从手机中获取的参数
1.2. 【渲染流程】
在每帧渲染前都会去设置左右眼的相机参数,这个是在GvrEye.OnPreCull中进行的,主要就是会设置相机的位置(左右眼位置的偏移)、投影矩阵,然后还有渲染纹理。也就是左右相机的渲染是离屏渲染的,渲染的图像是生成到GvrViewer.StereoScreen这个RenderTexture上的。这个RenderTexture是整个屏幕大小的纹理,左相机渲染到纹理的左半部分,右相机渲染到纹理的右半部分。
整个渲染流程是根据场景中相机的渲染顺序来的:
1)首先渲染的是PreRender,它的depth为-100。它的主要作用是清理背景,是通过直接设置Camera的Clear Flags和背景颜色来做的。这就是为什么看到渲染后的图像的空白部分是黑色的原因
2)接着渲染的是Main Camera,它的depth是0。不过理论上这个相机的渲染是可以不要的。但它是作为所谓的单目相机存在的,在某种情况下还是有用的,这个看后面的分析。这里即使渲染了,它是直接上屏的,但是会被后面渲染的内容覆盖
3)接着渲染的就是左右相机了,它们的depth也是0。如前面所说,这两个相机都是离屏渲染到同一个RenderTexture的左右两部分
4)最后渲染的是PostRender,它的depth是100,在其上面的GvrPostRender脚本的OnRenderObject中,根据Profile中的手机及镜片参数生成了变形网格,然后将第3步生成的RenderTexture以纹理贴到网格上,从而形成了变形的图像,然后调用Graphics.DrawMeshNow将图像绘制到屏幕
1.3. 【头部跟踪流程】
头部跟踪决定了在眼镜中看到的景象能随着头部的运动看到不同的景象。起点在GvrHead:
GvrHead.Update()->GvrViewer.UpdateState()->BaseVRDevice.UpdateState()->BaseVRDevice.GetHeadPose()->GvrViewer.DispatchEvents()
其中BaseVRDevice.UpdateState和BaseVRDevice.GetHeadPose会根据设备不同实现不同。对于Unity Editor的情况,可以通过鼠标 Atl/Ctrl的方式来模拟头部的运动。对于手机的情况,BaseVRDevice.GetHeadPose会通过动态库从手机传感器中取出数据。GvrViewer.DispatchEvents还可以分发按键等输入事件。
2. 【详细分析】
2.1. GvrViewer
GvrViewer为单例,必须把它添加到场景中的一个物体上,在前面提到的如果使用相机骨骼预制体,它位于GvrMain上面。它的作用是维护着一些全局的配置,比如是否启用VR模式(双屏模式)、变形校正的类型(没有变形、使用native层校正还是使用unity的校正)、最终渲染的纹理、手机及镜片的配置参数、头部及眼睛的姿态,然后创建并保存了具体设备对象(android、ios或者unity编辑器),使用者不直接与具体与设备打交道,而是通过GvrViewer作为代理。状态的更新、事件的分发也是在这里。所以,GvrViewer是总的接口类,开发者基本上就是与它打交道了,那还是把它public出来的接口列表一下吧:
接口(方法/属性/变量) | 注解 |
Instance | 全局实例 |
Create | 如果不使用相机骨骼预制体,可以在合适的地方主动调用Create创建GvrViewer实例 |
Controller | StereoController对象,主要用于控制眼睛/相机的一些参数,通常不会用到 |
UILayerEnabled | 控制是否启用2D UI显示。通常会启用,因为要显示左右屏分隔线 |
VRModeEnabled | 是否启用VR模式 |
DistortionCorrectionMethod | 变形校正方式,可以选择不校正、使用native方法校正和使用unity方式校正(使用Unity方式校正即为使用C#代码通过Unity的特性来校正,比如说Unity纹理、Unity网格) |
EnableAlignmentMarker | 是否显示左右屏分隔线 |
EnableSettingsButton | 是否显示设置按钮 |
BackButtonModes | 是否显示返回按钮,及返回按钮的显示场景 |
NeckModelScale | 是否模拟脖子(模拟脖子后视角会相应的有少许提高) |
AutoDriftCorrection | 是否修正陀螺仪的漂移现象 |
ElectronicDisplayStabilization | 是否做帧预测。这个是试验功能(0.8版本),缺省关闭 |
autoUntiltHead | Unity下使用鼠标模拟头部运动是否自动回正 |
UseUnityRemoteInput | 是否使用手机的远程输入功能 |
ScreenSize | 在Inspector选择模拟的手机种类 |
ViewerType | 在Inspector中选择模拟的镜片类型 |
NativeDistortionCorrectionSupported | 设备是否支持native方式的变形校正 |
NativeUILayerSupported | 设备是否支持在native层显示2D UI |
StereoScreenScale | 最终渲染纹理的放大倍数,决定了纹理的分辨率。分辨率越低,性能越高 |
StereoScreen | 最终的渲染纹理,左右两屏的变形纹理是直接渲染到同一个纹理的左右两半部分 |
OnStereoScreenChanged | 纹理修改的回调通知 |
Profile | 设备及镜片的配置参数 |
HeadPose | 获取头部的姿态(即头部位置跟踪) |
EyePose | 这个是眼睛相对于头部的偏移 |
Projection | 指定眼睛的投影矩阵 |
Viewport | 指定眼睛的视见区(通常就是左右眼的视见区分别占据左右两半屏幕) |
ComfortableViewingRange | 眼睛看场景中的物体的舒适距离范围,写死的0.4到100000米 |
DefaultDeviceProfile | 可以设置一个缺省的(设备)配置信息 |
OnTrigger | 扳机键按下回调 |
OnTilt | 横屏切竖屏回调 |
OnProfileChange | 配置改变回调 |
OnBackButton | 返回键按下回调 |
Triggered | 扳机键按下状态,供应用主动获取 |
Tilted | 横屏切竖屏状态,供应用主动获取 |
ProfileChanged | 配置改变状态,供应用主动获取 |
BackButtonPressed | 返回键按下状态,供应用主动获取 |
UpdateState | 更新状态(从设备中读取)、分发事件 |
PostRender | 将渲染后的纹理提交给native层处理(变形及渲染),仅在设备支持native层处理变形的情况下有用 |
Recenter | 将当前位置重置为中心(初始位置、原点、正向) |
ShowSettingsDialog | 显示/跳转设置界面 |
详尽的代码级的分析可以参看源码注释版。
2.2. GvrProfile
GvrProfile是管理设备(包括手机和眼镜)配置的地方,与VR相关的重要参数诸如屏幕大小、瞳距、视场角等都保存在这里。下面列举一些重要的参数
参数名 | 意义 |
Screen.width | 手机屏幕物理宽度 |
Screen.height | 手机屏幕的物理高度 |
Screen.border | 手机边框的物理厚度(横屏底部) |
Lenses.separation | 瞳距(两个镜片中心的距离) |
Lenses.offset | 镜片中心到眼镜承托手机底部(或顶部)的距离 |
Lenses.screenDistance | 镜片中心到手机屏幕的距离 |
Lenses.alignment | 手机与眼镜的对齐方式(通常是下对齐) |
MaxFOV.outer | 镜片的外fov(左镜片的左fov,右镜片的右fov) |
MaxFOV.inner | 镜片的内fov(左镜片的右fov,右镜片的左fov) |
MaxFOV.upper | 镜片的上fov |
MaxFOV.lower | 镜片的下fov |
Distortion.coef | 形变系数(布朗模型) |
关于各个参数的获取,参看:https://support.google.com/cardboard/manufacturers/checklist/6322188
screenDistance图示:
separation图示:
offset图示:
MaxFOV图示:
坐标系以镜片中心为原点,向右、向上为X、Y方向正方向
关于形变参数,使用的是布朗(Brown)1966年提出的形变模型:
其中,(xd, yd)是变形后的坐标,(xu, yu)是未变形的坐标,kn为形变系数,r为形变半径:
(xc, yc)是形变中心(又叫主点,principal point),因此r即为未变形点到形变中心点的距离。Cardboard使用的是简化的理想模型,只使用了两个形变系数k1和k2。
另外这个类中还有几个非常重要的参数计算及公式计算函数,它们是:
GetLeftEyeVisibleTanAngles:这个函数计算的手机屏幕通过透镜查看能够看得见的区域对应的视场角(FOV)的tan值。受限于透镜的光学FOV及手机屏幕的大小,再加上手机的边框,眼镜的承托面等各种参数,会导致通过透镜看到的屏幕上的范围会限定在一定的范围。这个函数就是计算这个显示范围的。限定显示范围可以减少渲染的区域从而提升性能。
实际做法是将透镜的理论光学FOV与实际的根据屏幕参数及眼镜结构参数计算出来的实际FOV进行比较,选择更小的一组FOV以确保通过透镜能看到最大的屏幕区域。注意,这里是实际的情况,需要考虑到透镜的变形,所以计算实际FOV时会考虑到变形。
GetLeftEyeNoLensTanAngles:与上面是类似的,只不过这里是不考虑变形的情况,它是上面的做法是相反的,即光学FOV会被做逆变形,而实际FOV则不再考虑变形。
GetLeftEyeVisibleScreenRect:获取可见屏幕区域的范围(与全屏的比例)
里面还有解最小二乘矩阵方程式(solveLeastSquares)的算法及采用逼近算法计算近似逆变形算法(ApproximateInverse),这两个算法这里不作分析了。
详尽的代码级的分析可以参看源码注释版。
2.3. GvrEye
GvrEye是主要作用是设置立体相机(投影矩阵、视见区、相机姿态等),渲染视见内容到纹理上。它挂在相机骨骼上的左右相机上。它会从Main Camera(单目相机)上拷贝参数,然后根据具体设备(手机及眼镜)参数设置自己特有的参数。其中的主要方法有:
OnPreCull:这个每帧都会调用,它是所有设置的起点。因为可能在运行过程中动态切换参数配置,为了避免用户产生眩晕等不舒适感,会对相机的参数进行平滑的修改,也就是会做动画,因此需要每帧调用。在OnPreCull中会调用SetupStereo
SetupStereo:设置的总入口,它会调用UpdateStereoValues设置投影矩阵及视见区,它也会根据观察设置的感兴趣的物体的舒适度调整相机的位置,这个过程会进行平滑过渡
UpdateStereoValues:它会先调用CopyCameraAndMakeSideBySide拷贝主单目相机的参数,然后根据设备参数更新投影矩阵及视见区
CopyCameraAndMakeSideBySide:它从主单目相机拷贝参数,然后会设置自己的一些参数,根据瞳距设置相机位置以模拟眼睛的位置以及初始化视见区
这个类里面会有一些数学计算,主要涉及的是左右分屏的视见区的设置、投影矩阵的修正、视见区的修正等。分屏视见区的设置主要是通用考虑到非全屏相机(画中画)的情况,将原始非全屏单目相机的视见区等效映射到左右半屏的视见区,要考虑到分屏后屏幕长宽比变化的情况。投影矩阵的修正是由于从底层取出的投影矩阵的远近端值是不准确的,修正后采用了单目相机的远近端值。
关于相机的投影矩阵,计算公式是这样的:
其中l、r、t、b、n、f分别为视见体的左右上下近远面的坐标。对于一个对称的视见体,可以简化为:
在上面的矩阵中,[1,1]元素为n/t,它实际上可以代表垂直方向的FOV,这在代码的很多地方都有用到。看下面的图就知道了:
上图为相机视见体示意图,下图为n/t与FOV关系示意图,从图中可以看出,投影矩阵中的[1,1]为n/t,显然就是垂直方向FOV的一半的ctan值。
详尽的代码级的分析可以参看源码注释版。
2.4. GvrHead
从物理意义上来说,GvrHead就代表人的头部,在这里,它的主要作用是保持头部的跟踪,即将从手机里取出的陀螺仪数据应用到Unity场景里的GvrHead所在的物体上,头部的运动会自然带动眼睛(GvrEye)的运动,从而从相机中可以看到随动的场景。实际上,这个脚本可以加到任何需要头部随动的物体上。
它的实现很简单,都在UpdateHead方法里面,它在Update或者LateUpdate里面调用,会调用GvrViewer.UpdateState来从手机获取陀螺仪参数,然后更新到GvrHead所在的物体上。
详尽的代码级的分析可以参看源码注释版。
2.5. GvrPreRender
这个脚本与GvrPostRender一起完成了完整的渲染工作,它的作用是清屏,即在GvrPostRender绘制前先把屏幕清空。它必须依附在一个相机对象上,通过设置相机的depth参数保证其是最先渲染的相机。这个脚本很简单,详尽的代码级的分析可以参看源码注释版。
2.6. GvrPostRender
这个脚本是实现最终的变形的地方,具体做法是先生成一个变形的网格,然后把左右眼渲染出来的图像贴到变形网格上,就能看到渲染出来图像的变形效果了。关于它的原理可以通过下面的图示来解释:
其中的核心算法在以下三个方法中实现:
ComputeMeshPoints:这个方法生成的是上图网格中的所有顶点。它有两种方式,一种是生成桶形变形网格,然后把纹理贴上去,一种是生成枕形变形的纹理坐标,然后贴到未变形的网格上。缺省是第一种方式,具体做法是将一个顶点转换成视场角的tan值(tan值与视场角、与x/y坐标都是成正比的),然后再转换成到中心点的半径值,根据Brown变形算法计算出变形后的半径值,再根据比例得到变形后的x、y坐标,然后再按比例归一化到[0,1]之间。左右两眼(分屏)的变形网格(顶点)是分别生成的,最后会把两个变形网格整合成一个全屏的网格。对于第二种方式,生成的具体过程是一样的,只是使用了Brown变形算法的逆算法来生成枕形变形的纹理网格。
ComputeMeshColors:这个方法是为上面生成的每个网格顶点生成颜色,它的作用是让边界的颜色过渡更自然。因为通常手机屏幕上眼睛看不到的区域会被绘制成黑色(GvrPreRender中设置的背景清除色)。具体做法就是,对于上面的第一种对网格进行变形的方式来说,就是简单地将网格的最外一层顶点设成黑色,其它的顶点设成白色。而对于上面的第二种做法,它会将纹理坐标反过来再转换左右分屏的坐标,然后判断这些坐标有没有超过范围,超出范围的顶点颜色会设置成黑色。至于颜色的过渡是由OpenGL来完成的,使用过OpenGL的都知道,为三角形的每个顶点设置颜色,整个三角形面片的颜色会被自动插值,从而有渐变效果。
ComputeMeshIndices:在Unity/OpenGL中,所有的物体都是由三角形组成的,这个方法就是根据上面生成的网格顶点来生成三角形顶点索引数组。为了让所谓的晕影(vignette)效果更好,它做了点小技巧,就是将左右半屏再划分成4个区域(象限),左下和右上区域三角形的对角线是左下到右上方向的,而左上右下区域的三角形的对角线是左上到右下方向的。因为这样一来变形网格的四个角上三角形顶点都是锐角顶点,确实进行颜色插值插出来的值会更加自然。如下图所示:
上图中分别在第二、三象限的最角上的两个三角形做了示例,演示了一个网格四边形划分成两个三角形的顶点缠绕顺序。
详尽的代码级的分析可以参看源码注释版。
2.7. StereoController
这个脚本的主要作用是定义了一些控制渲染的参数,同时创建相机骨骼(如果有需要的话)也是在这里完成的,然后还有一个计算眼睛(相机)位置的辅助函数(ComputeStereoEyePosition)。同时它里面还有一个在一个帧的开始启用单目相机,在渲染前禁用单目相机的逻辑,以节省渲染开支。之所以要在渲染前启用,是为了能让其它可能使用它的地方能引用到,因为单目相机可能是场景中的主相机,有的代码可能使用Camera.main引用它。
下面列举脚本中定义的渲染控制参数及其意义:
参数名 | 含义 |
directRender | 是否直接上屏,缺省为true。否则会离屏渲染,然后使用StereoRenderEffect来做后置处理 |
keepStereoUpdated | 是否每帧都重新计算立体相机的配置(相当于实时计算),缺省为false |
stereoMultiplier | 这个是瞳距的缩放系数,在[0,1]之间,为0即关掉立体效果(瞳距为0),1表示使用镜片的间距参数。系数越小(相当于两只眼睛距离越小),立体感越小 |
matchMonoFOV | 立体相机FOV与单目相机FOV的匹配系数,在[0,1]之间。缺省为0,即使用立体相机(VR眼镜的镜片参数)FOV,1表示完全使用单目相机的FOV。通常情况下应该保持为0,设为不为0时可以达到某些特殊的效果 |
matchByZoom | 与上面的matchMonoFOV配合使用,表示上面的FOV匹配的方式。0表示通过移动相机来缩放FOV,1表示直接缩放FOV,两者之间表示混合方式 |
centerOfInterest | 通过移动相机可以达到匹配FOV的目的,此时需要指定一个感兴趣的物体(或者位置),这样将相机相对于这个物体移动就能改变FOV |
radiusOfInterest | 如果上面的COI是场景中的一个物体,那么这里可以指定这个物体的平均大小。指定它是为了避免当COI靠相机太近时会导致眼睛的不舒服,从而进行一定的调整(调整瞳距以缓解视差) |
checkStereoComfort | 是否检查舒适度,即COI是否在指定的舒适距离内(0.4到1000米)。 |
stereoAdjustSmoothing | 当需要调整FOV时,可以平滑地进行 |
screenParallax | 画中画窗口在立体相机中的视差系数,实际上就是调节画中画窗口在场景中的虚拟深度 |
stereoPaddingX | x方向的padding值。用于将画中画窗口往中间移,边缘由于镜片原因可能会看不清楚 |
stereoPaddingY | y方向的padding值 |
renderedStereo | 是否立体渲染(即是否VR模式) |
ComputeStereoEyePosition方法会根据上面的FOV调整的相关参数来计算左右眼(相机)的位置。通常情况下左右眼的位置就是镜片的间距(瞳间距)。其中关于相机移动距离的计算如下:
float scale = proj11 / cam.projectionMatrix[1, 1]; float offset = Mathf.Sqrt(radius * radius (distance * distance - radius * radius) * scale * scale); float eyeOffset = (distance - offset) * Mathf.Clamp01(matchMonoFOV) / zScale;
可以用图来解释,图中的圆表示COI。
首先投影矩阵中的[1,1]元素代表的是垂直方向的FOV(实际上是半FOV的ctan值),scale即立体相机FOV与单目相机FOV的缩放系数。Offset算出来的是FOV调整后的COI与相机的距离,即上图的d’。eyeOffset即眼睛(相机)要移动的距离,在上图中演示的是将COI往眼睛方向移,这与眼睛往COI方向移效果是一样的。
详尽的代码级的分析可以参看源码注释版。
2.8. BaseVRDevice
这个是与具体设备打交道的基类,它定义了相关的接口,与VR设备进行数据交互,比如Profile参数、相关状态的获取等。其中定义的接口如下:
接口名 | 含义 |
Init | 初始化 |
SetUILayerEnabled | 设置是否显示UI层 |
SetVRModeEnabled | 是否启用VR模式 |
SetDistortionCorrectionEnabled | 是否使用native层的变形算法 |
SetSettingsButtonEnabled | 是否显示设置按钮。点击这个按钮会跳转到Cardboard的设置 |
SetAlignmentMarkerEnabled | 是否显示左右分屏对齐线 |
SetVRBackButtonEnabled | 是否显示返回按钮 |
SetShowVrBackButtonOnlyInVR | 是否仅在VR模式下显示返回按钮 |
SetNeckModelScale | 设置脖子的模拟系数 |
SetAutoDriftCorrectionEnabled | 是否自动修正陀螺仪漂移 |
SetElectronicDisplayStabilizationEnabled | 是否开启帧预测功能 |
UpdateState | 从设备中获取相应的状态 |
UpdateScreenData | 更新(重新计算)屏幕参数 |
PostRender | 使用native校正变形的情况下,将渲染后的分屏图像提交到native层处理 |
另外,此类中保存了一些与设备相关的数据,包括Profile、头部/眼睛位置、左右眼投影矩阵、左右眼视见区、按键状态等。
设备相关类的继承关系如图:
各类的作用如下:
类名 | 功能 |
BaseVRDevice | 纯虚类,代表一个VR设备的虚类,它是所有设备类的基类,主要定义了相关的接口,与VR设备进行数据交互(Profile参数获取等) |
EditorDevice | 在Unity编辑中调试运行时所使用的一个虚拟设备类,主要是从键盘鼠标这样的输入设备获取设备来模拟真实手机设备的事件 |
BaseAndroidDevice | Android设备的基类,它的主要作用是封装对java对象的调用 |
GvrDevice | 此类作为具体设备的直接基类,它封装了与native库的通信 |
AndroidDevice | Android设备类,从BaseAndroidDevice及GvrDevice派生,主要将一些操作回调android层的java类对象 |
iOSDevice | iOS设备类,从GvrDevice派生,因为GvrDevice同时作为Android和iOS设备基类,Android的一些操作是回调的java代码,而iOS则仍然是通过调用native的方式 |
详尽的代码级的分析可以参看源码注释版。
2.9. 凝视系统
2.9.1.GazeInputModule
这个类的作用是基于Unity的事件系统将凝视当作一种输入事件,它从BaseInputModule派生。使用的时候把它加到EventSystem当中,对于Canvas UI,能自动接收到UI事件,如果要与场景中的3D物体交互,则需要在相机上添加PhysicsRaycaster。凝视作为输入的核心是从相机发出射线与场景中的UI及物体相交,从而作为选中状态的条件,同时配合扳机及鼠标操作实现点击和拖拽功能。另外,这个类还实现了一种准星状态更新的逻辑。
2.9.1.1. 凝视的核心
凝视的核心在于EventSystem.RaycastAll,它的声明是:
public void RaycastAll(EventSystems.PointerEventData eventData, List raycastResults);
它的依据是相机与屏幕上的一个点,这两点之间发出一条射线,与场景中的UI或者3D物体相交,返回是所有相交的物体列表。这里相机是场景中的单目相机。
Unity的事件系统中有一个核心的数据结构,那就是PointerEventData,整个事件数据的传递都通过它,在GazeInputModule中会对这个数据结构进行多次填充,对其中的几个有趣的成员进行分析:
clickTime:这个记录了点击开始的时间。所谓点击是指在一定时间内扳机或鼠标按下与松开的过程。而在这个时间内没有松开则不是点击,而是拖拽。
eligibleForClick:这个正是标识上面说的点击还是拖拽。为true表示当前是点击操作。这个标志会在点击开始的时候和clickTime一起设置
delta:这个表示两帧之间事件位置的偏移。因为这里是通过凝视触发事件的,这里用的是头部位置的偏移。因为delta是Vector2类型,所以需要将空间的位置偏移(Vector3)转换为Vector2类型,转换是在NormalizedCartesianToSpherical中做的,实际上就是将笛卡尔直角坐标系转换成球形极坐标系。
private Vector2 NormalizedCartesianToSpherical(Vector3 cartCoords) { cartCoords.Normalize(); if (cartCoords.x == 0) cartCoords.x = Mathf.Epsilon; float outPolar = Mathf.Atan(cartCoords.z / cartCoords.x); if (cartCoords.x < 0) outPolar = Mathf.PI; float outElevation = Mathf.Asin(cartCoords.y); return new Vector2(outPolar, outElevation);}
上面的代码可以用下面这张图示来解释:
球形极坐标就像地理上的经纬度,是用两个角度来表示的。这里分别用绕x轴和绕y轴的两个角度表示,代码中outPolar就是图中的θ,outElevation就是图中的ψ,注意在函数的最开始做了归一化,因此向量的长度即长边为1。
2.9.1.2. 点击与拖拽
点击与拖拽是两种类型的动作。在GazeInputModule中有一个成员变量clickTime,设置的是0.1,这个变量的作用是判断一次按键(扳机或者鼠标)动作是点击还是拖拽,如果按键(扳机)按下与松开的时间间隔小于clickTime,那么就是点击,否则视为拖拽。
点击与拖拽的处理在代码中有三个函数来处理,它们依次是HandleTrigger、HandlePendingClick、HandleDrag。HandleTrigger就是按键(扳机)第一次按下的处理,它会设置前面说的PointerEventData数据结构中的clickTime和eligibleForClick字段。然后会等待全局的clickTime时间过去,这个时间过去后,如果按键松开了,那么就认为是点击事件,就交由HandlePendingClick处理,而如果按键没有松开,那么认为是拖拽,则交由HandleDrag处理,此时eligibleForClick会被设为false。
点击或者拖拽事件都是通过ExecuteEvents.Execute这个辅助方法向特定的物体发出的,这些事件包括比如光标(鼠标)按下、松开、开始拖拽、正在拖拽、结束拖拽等。
2.9.1.3. 准星
GazeInputModule里面还实现了一个简单的准星系统,通过回调IGvrGazePointer接口实现对象的接口来更新准星的状态。在GVR的示例中,有一个GvrReticle类实现了IGvrGazePointer接口,并实现了准星的显示与动画等。
在GazeInputModule中,对准星状态的更新包括凝视选中一个物体、在物体上停留、离开物体、开始点击、点击结束等。
详尽的代码级的分析可以参看源码注释版。
2.9.2.IGvrGazePointer
这个如上面所说,就是定义了准星(凝视指示器)状态的相关接口,实现类可以实现相应的方法来获取凝视的相关状态。状态的回调源点在GazeInputModule当中。在UI目录下还有另一套凝视系统(GvrGaze.cs)也会使用这个接口,这个后面再分析。接口的方法列表如下:
方法名称 | 含义 |
OnGazeEnabled | 启用凝视时调用 |
OnGazeDisabled | 禁用凝视时调用 |
OnGazeStart | 凝视选中某个物体时调用 |
OnGazeStay | 凝视停留在某个物体时调用 |
OnGazeExit | 离开某个之前凝视的物体时调用 |
OnGazeTriggerStart | 开始按下扳机时调用 |
OnGazeTriggerEnd | 松开扳机时调用 |
GetPointerRadius | 返回准星的内外径。用于防止准星在物体边界时出现抖动。如果返回的半径为0,则使用射线来投射 |
详尽的代码级的分析可以参看源码注释版。
2.9.3.GvrReticle
准星的一个实现,即对上面的IGvrGazePointer的一个实现,主要是显示了一个准星动画,选中一个物体时,准星放大,没有选中物体(或者物体不可选中)时准星变成一个点。
首先准星是由一个圆环组成的,它是由在代码中生成的顶点数组添加到MeshFilter中绘制的,示意图如下所示:
它将一个圆环分成很多(20)小份,将每个小份生成两个三角形面片。需要注意的是,在C#代码中生成的内外环的顶点坐标是一样的,并且是以长度1为基准的,而且使用了z坐标,分别为1和0,这会让人很疑惑。原因是,这里只是生成了基准坐标,真正的显示坐标是在GvrReticleShader.shader中完成的。在这个shader脚本中将C#中生成的基准坐标根据传进来的真实(变化)的圆环内外径生成实际坐标,z坐标的0和1只是用来标识是内径还是外径的。
内外径的实际大小是根据下面图示计算的:
即用眼睛(相机)到准星的距离乘以视线夹角的正切得到的就是内外环的直径了。做动画时就是根据初始内外径到最终内外径进行插值。
详尽的代码级的分析可以参看源码注释版。
2.9.4.GvrReticleShader.shader
虽然对shader脚本不是很熟,但还是能大概看懂它的一个功能是对准星顶点数组进行转换。最重要是下面两行:
float scale = lerp(_OuterDiameter, _InnerDiameter, i.vertex.z);
这行所谓的scale其实就两个值,因为在C#中给顶点的z坐标赋的值,对外径是0,对于内径是1,因此这里的lerp插值后,scale对于外径就是_OuterDiameter,对于内径就是_InnerDiameter,而这两个值就是C#中传进来的内外直径值
float4 vert_out = float4(i.vertex.x * scale, i.vertex.y * scale, _DistanceInMeters, 1.0);
C#中生成的x/y坐标是以1为基准的,这里乘以scale(内外径)后就变成了实际坐标了。然后z坐标也设置成了眼睛/相机与准星的实际距离。
2.9.5.IGvrGazeResponder
这是一个简单的凝视(动作)响应(回调)接口。上面的IGvrGazePointer也有凝视相关的回调,只不过它主要是用于凝视指示器(即准星)的,这个接口则可以用于其它的物体,任何想获取凝视相关状态的都可以实现此接口。它只有三个接口:
OnGazeEnter | 当一个物体被凝视的时候,挂在上面的实现了此接口的脚本将收到这个回调 |
OnGazeExit | 凝视离开物体时收到此回调 |
OnGazeTrigger | 凝视期间按下扳机时调用 |
2.9.6.GvrGaze
这是独立于GazeInputModule之外的另一套凝视系统,它需要挂到相机物体上。它通过从当前相机发出一条柱状射线(即带粗细的射线,其半径为准星的半径)与场景中的物体相交(Physics.SphereCast),从而实现凝视的过程。进入及退出凝视会同时通知到准星和可交互的被凝视物体(实现了IGvrGazeResponder)。准星是圆环状的,进入凝视判断是根据准星的内径,离开凝视判断是根据准星的外径。其中定义的主要成员如下:
名称 | 含义 |
PointerObject/pointerObject | 设置/获取准星对象 |
pointer | 准星脚本对象 |
cam | 当前相机 |
mask | 可被凝视物体的层过滤器 |
currentTarget | 可交互的当前凝视物体脚本 |
currentGazeObject | 当前凝视物体 |
lastIntersectPosition | 最后/当前(被凝视物体上的)凝视点 |
isTriggered | 当前扳机是否按下 |
LateUpdate | 更新凝视及扳机状态的地方 |
HandleGaze | 处理凝视(包括进入凝视、保持凝视及退出凝视) |
FindGazeTarget | 以准星内径查找凝视物体(通过柱状射线投射) |
IsGazeNearObject | 以冷得外径判断当前凝视物体是否仍被凝视 |
HandleTrigger | 处理扳机的按下及松开状态 |
详尽的代码级的分析可以参看源码注释版。
2.10. Daydream控制器(手柄)
GVR里面还实现了一套针对Daydream控制器的输入系统。
2.10.1. GvrController
这个类是对外提供Daydream控制器接口的主类,调用者通过它就能获取到控制器的输入信息,包括按键状态、陀螺仪数据及加速度数据等。它是个单例,要获取控制器的状态及数据,可以直接使用对应的静态变量就可以了。它是个MonoBehaviour,在一个场景中只能有一个这个脚本,可以把它加到场景中的一个对象上,或者直接使用GvrControllerMain这个预制体。它对外提供的控制器的状态及数据有:
名字 | 含义 |
State | 获取控制器的连接状态 |
Orientation | 获取控制器在空间中的角度,以四元数返回 |
Gyro | 获取控制器的陀螺仪数据,以Vector3返回,即绕控制器三个轴的角度。只有在Inspector启用了陀螺仪才有效,缺省为不启用 |
Accel | 获取控制器的加速度信息,以Vector3返回,即控制器三个轴方向的加速度。只有在Inspector中启用了加速度时才有效,缺省为不启用 |
IsTouching | 用户是否触摸了控制器的触控板 |
TouchDown | 用户是否首次触摸触控板。这个标志仅在触摸触控板后的第一帧为true,然后就会变为false |
TouchUp | 用户是否刚刚离开触控板。这个标志仅在离开触控板后的第一帧为true,然后就会变为false |
TouchPos | 触摸触控板时在触控板上的位置,以Vector2返回 |
Recentering | 用户是否正在执行中心重置手势 |
Recentered | 表示刚刚完成中心重置手势。仅在完成重置后的第一帧为true,然后又会变为false |
ClickButton | 用户是否按下了点击按钮,它是一个持续的状态 |
ClickButtonDown | 用户是否刚按下了点击按钮。仅在按下点击按钮的第一帧时为true,然后又会变成false |
ClickButtonUp | 用户是否刚松开了点击按钮。仅在松开点击按钮的第一帧时为true,然后又会变成false |
AppButton | 用户是否按下了app按钮,它是一个持续的状态 |
AppButtonDown | 用户是否刚按下了app按钮。仅在按下app按钮的第一帧时为true,然后又会变成false |
AppButtonUp | 用户是否刚松开了app按钮。仅在松开app按钮的第一帧时为true,然后又会变成false |
ErrorDetails | 当连接出现错误时,通过这个属性可以获取出错描述信息 |
详尽的代码级的分析可以参看源码注释版。
2.10.2. ControllerState
描述控制器状态的类,这是个内部类,它与GVR控制器的C API中定义的是一样的,基本上就是上面表格中定义的那些属性对应的变量定义。
2.10.3. IControllerProvider
针对不同平台可以有不同的Provider,这个就是不同Provider的接口定义,只提供了三个接口:
接口名 | 含义 |
OnPause | 通知控制器提供者应用暂停了 |
OnResume | 通知控制器提供者应用继续了 |
ReadState | 这个是核心,从Provider中获取控制器的状态 |
2.10.4. ControllerProviderFactory
Provider工厂,针对不同平台创建不同的Provider实例。目前只支持两个平台,一个是Android平台,一个是Unity编辑器平台中。Android平台创建的是AndroidNativeControllerProvider,Unity编辑器创建的是EmulatorControllerProvider,这个是可以用手机上的模拟器通过USB或者Wifi连接到PC,然后模拟控制器作为Unity编辑器中的控制器输入。对于不支持的平台,创建的是DummyControllerProvider,这是一个假的Provider,不提供实际功能。
2.10.5. AndroidNativeControllerProvider
这个就是Android平台的provider的实现,它的工作就是从libgvrunity.so中导入了相关的函数,然后调用这些函数从so中取出Daydream控制器的状态数据,然后转换成C#端的数据结构。从so中导入的几个主要函数有:
名称 | 含义 |
gvr_controller_init_default_options | 读取Daydream控制器的初始配置参数 |
gvr_controller_create_and_init_android | 创建并初始化控制器的android环境 |
gvr_controller_destroy | 销毁 |
gvr_controller_pause | 暂停数据采集 |
gvr_controller_resume | 开始数据采集 |
gvr_controller_read_state | 从控制器读取状态数据 |
ReadState是IControllerProvider接口方法,对外提供读取控制器状态数据功能,它会调用gvr_controller_read_state从so中读取数据。另外一个需要注意的点是它会对数据进行转换,一个重点是Daydream控制器与Unity的空间定义是不一样的,Daydream控制器为右手法则,x向右,y向上,z向外,而Unity为x向右,y向上,z向里。
详尽的代码级的分析可以参看源码注释版。
2.10.6. EmulatorControllerProvider
这个是针对Unity编辑器而提供的一个模拟器的实现,即可以通过手机上的模拟器通过USB或者Wifi把控制器数据模拟给Unity编辑器,以方便调试。它的原理是通过USB或者Wifi与手机建立连接,然后以socket的方式与手机通信,以Google Protocol Buffer作为协议。这里的实现比较复杂,由于与VR没有什么关系,暂时就不分析了。不过可以作为一种与手机通信方式以及使用手机作为输入的一种很好的参考。
2.11. GVR音频
GVR提供了一个可以提供空间音效音频的功能,核心是以libaudioplugingvrunity.so native方式提供的。它其实是以Unity原生的AudioSource提供的空间音效与基础提供定制化的功能的。使用方法也与Unity原生的AudioSource是类似的,它有一个GvrAudioSource的类,作为音频输入源,可以添加到场景中的音频源物体上,同时它也有一个GvrAudioListener类作为音频监听者,可以把它添加到场景中的音频监听者物体上。同时有一个GvrAudio类作为核心主类,与native层的so进行通信。GVR音频还支持房间内的音效效果,可以通过GvrAudioRoom类来定制房间音效的效果,可以定义房间6个墙壁的材质,不同的材质对声音的反射率是不一样的。
2.11.1. GvrAudioSource
重点讲一下GvrAudioSource的实现,因为它是提供给开发者的接口,把它加到场景中的音频源物体上即可。它的实现实际上仍然是采用Unity原生的AudioSource作为音频输入,然后把AudioSource的音频输出到自定义的一个混音器GvrAudioMixer上面,通过个混音器将音频数据交给native层实现空间音效、房间音效、声音遮挡等效果。它提供的定制属性参数有:
属性 | 含义 |
bypassRoomEffects | 是否旁路房间效果 |
directivityAlpha | 指向样式因子 |
directivitySharpness | 指向样式级数 |
gainDb | 输入增闪 |
occlusionEnabled | 是否启用声音遮挡效果 |
playOnAwake | 是否在启动时自动播放 |
rolloffMode | 衰减模式 |
spread | 声音扩散角度 |
clip | 音频源 |
isPlaying | 是否正在播放 |
loop | 是否为循环播放模式 |
mute | 静音 |
pitch | 音高 |
volume | 音量 |
maxDistance | 声音停止衰减的最大距离 |
minDistance | 声音不再随距离的减小而增大的最小距离 |
hrtfEnabled | 是否启用HRTF |
该类还提供了针对音频的一些基本操作:
操作 | 含义 |
GetOutputData | 获取原始输出数据 |
Pause | 暂停播放 |
Play | 播放 |
PlayDelayed | 延迟播放 |
PlayOneShot | (使用最大音量)直接播放一个音频片断 |
Stop | 停止播放 |
UnPause | 继续播放 |
它的实现原理是在Awake中初始化Unity原生AudioSource,并把它的输出挂接到GVRAudioMixer这个混音器上。然后在InitializeSource中初始化GvrAudio,创建GVR音频源,把它设为Unity原生AudioSource定制化的空间音效,然后在每帧中根据设置的音频更新时间间隔调用GvrAudio.UpdateAudioSource来将相关的参数传递到native层,native层将根据音频源的位置以及监听者的位置计算远近、方位、遮挡、房间反射等从而生成空间音效。
另外在Unity编辑器中,还会在DrawDirectivityGizmo函数中画一个表示声音扩散范围的Gizmo小组件。
详尽的代码级的分析可以参看源码注释版。