联系QQ:1131042645
本次作业的主要目的是从零搭建一个笔记本环境下的低成本VR架构。现有的PC端VR硬件载体往往具有很高的硬件要求。在VR眼镜(外接式头戴式载体)方面,Oculus Rift、HTC Vive、Playstation VR都价格不菲,价格超越一台普通的笔记本电脑。在运算载体方面,除非是特制的游戏平台(PS4等),如果使用PC设备,往往对于硬件配置的要求十分高,显卡的配置直逼深度学习、区块链等高强度计算领域的需求。与此同时,低成本的VR设备往往是在手机上运行的,这虽然大大降低了成本,但是手机的输入方式单一(在VR模式下可以链接蓝牙手柄但是二次开发困难),陀螺仪漂移严重,并且在某些操作系统(例如iOS)上部署成本高,计算力有限,在VR的使用方面具有很大的限制。
在这种情形下上,如果仅仅想要开发一些实验性质或者演示性质的微型PC端VR系统时,硬件成本就成为了至关重要的限制因素。在VR系统开发初期,或者对于一个非商业性质的VR演示系统的开发,高昂的设备费用是不可接受的。因此充分利用常见设备中的各个系统部件搭建一套PC端的低成本VR架构是一个既有意义又充满挑战的工作,因此作业选题为从零搭建低成本PC端VR架构。
在此架构下,可以低成本,高效率地部署一些实验性质的VR应用演示。架构主要包含以下四个部分:基于openGL的双目渲染模块、基于openCV的VR眼镜位姿识别模块、基于openAL的3D音效模块,基于XDisplay的基本通信架构,基于freeGLUT的程序运行架构。具体架构如图所示:
图1 系统整体架构
如架构所示,系统的整体运行流程在freeGLUT的流程下进行,由freeGLUT核心流程架构控制程序的整体运行。系统的输入主要为头部姿态信息以及设备的输入信息(例如键盘或者外接手柄等等),系统的输出为视觉已经听觉效果。视觉效果通过插入手机的手机式VR眼镜进行显示,与此同时听觉效果通过耳机播放3D音效。
在最后,我们实现了一个在此VR架构上的应用。演示了相关的效果,验证了系统的可行性。本作业所有的相关成果全部在个人博客进行了开源展示,并且本作业文本内容也会以博客的形式进行公开,所有源代码遵守BSD开发协议。
本次作业中已经成功研发并且可以进行部署运行,由于其中重要的点在于低成本,因此在此列出相关材料费用以及成本核算。需要注意的是,成本是不包括笔记本电脑以及手机的。而本工程对于笔记本电脑和手机的配置要求也会列出。
表1 笔记本电脑配置要求
项目 | 要求 |
---|---|
CPU | Intel I3 6100 同级及以上 |
GPU | NVIDA GTX 650 同级及以上 |
声卡 | 集成声卡 |
摄像头 | 位于屏幕上方的任意像素摄像头 |
表2 手机配置要求
项目 | 要求 |
---|---|
操作系统 | iOS 9 或者 Android 4.0 同级及以上版本 |
表3 硬件成本核算
项目 | 数量 | 价格(元) |
---|---|---|
暴风魔镜S1头戴式VR眼镜 | 1 | 249 |
绿色光信标 | 5 | 25 |
数据连接线 | 1 | 19 |
总计 | 292 |
从表 1和表 2 中可以看出,系统对于运行硬件的要求十分低,因此日常使用的设备便可以满足要求。我就是使用自己日常使用的笔记本电脑以及手机搭建并且运行的此套系统,因此笔记本电脑以及手机完全可以不计入成本之中,任意一个人都是100%同时拥有这两种设备的,并不需要另外购买。而从表 3中可以看出,整套系统的整体硬件价格仅仅不到300元,这与市面上动辄5000元的价格具有天壤之别。虽然性能上差异较大,但是一方面这只是一个作业,是一次尝试与练习,另一方面该架构的定位也仅仅是进行简单的演示性VR以及实验性VR系统的开发,并不是为了商业软件甚至大型游戏准备的架构。
程序语言:C++
依赖开源库:openCV、openGL、openAL、freeGlut
依赖于作者本人开源代码: MGLTools类。该C++类提供了openGL下常见的指令的集总,以及计算机图形学常用数据结构以及计算公式的定义。遵守BSD协议。
本作业文档以及相关代码,包含其中的图片、表格将全部在个人博客以及Github进行开源。相关代码遵守BSD开源协议。可应用于任何领域但是需要在源码和手册注明作者。
个人博客:https://blog.csdn.net/anying0
架构代码Github地址:https://github.com/anying0/simpleGLVR
范例工程CSDN下载:下载链接
架构主要包含以下四个部分:基于openGL的双目渲染模块、基于openCV的VR眼镜位姿识别模块、基于openAL的3D音效模块,基于XDisplay的基本通信架构,基于freeGLUT的程序运行架构。具体架构如图所示:
双目渲染的核心技术在于以下三点:1)双目位置的计算。2)利用openGL函数进行渲染区域的分割。3)对视角进行操作后分别进行绘制。以下将分别介绍。
计算双目位置首先需要选定一个合适的角度体系。常用的角度表达体系分为三类:1)欧拉角体系。2)四元数体系。3)旋转矩阵体系。其中,欧拉角体系可以利用三个值确认一个物体的角姿态,但是存在万向节锁死的问题。四元数体系可以完善的表征角姿态信息,但是其不直观,需要换算,并且无法线性叠加。旋转矩阵体系是最适合叠加的体系,但是矩阵中的元素是三角函数,而不是角度信息本身,又太过偏向于底层,在顶层设计时比较麻烦,不应该直接使用。
欧拉角(Eulerian angles)用来确定定点转动刚体位置的3个一组独立角参量。在本项目中采用欧拉角体系进行双目位置的定位与运算。由于欧拉角体系的基本逻辑是坐标轴旋转,因此需要构建一个用于旋转坐标系的计算类。
vertexTransform.h
class vertexTransform
{
private:
vertex x;//X轴
vertex y;//Y轴
vertex z;//Z轴
public:
vertexTransform();
void showBase();//显示所有的轴
void init();//初始化坐标系
void clear();//重置坐标系
void rotate(vertex axis,float ang);//围绕转轴axis旋转坐标系ang度。
void move(vertex in);//移动坐标系
vertex get(vertex in);//得到当前坐标系下in向量(点)在母坐标系下的坐标。
};
该类主要储存了一组坐标系,然后可以通过调用函数来旋转和移动坐标系的位置。最后可以通过函数get()获得坐标系内点坐标对应母坐标系的坐标。
其中最核心的函数式rotate函数,也是欧拉角旋转的实现函数。可以让坐标系围绕系内某个轴进行一定角度的旋转。函数代码如下:
vertexTransform.cpp
void vertexTransform::rotate(vertex axis, float ang)
{
vertex a,b,c;
a = MGLTools::mult(x,axis.x);
b = MGLTools::mult(y,axis.y);
c = MGLTools::mult(z,axis.z);
a = MGLTools::add(a,b);
a = MGLTools::add(a,c);
x = MGLTools::rotate(x,a,ang);
y = MGLTools::rotate(y,a,ang);
z = MGLTools::rotate(z,a,ang);
}
该代码首先将axis输入轴转换为了母坐标系下的轴,然后再将x、y、z坐标轴分别沿着该坐标轴进行旋转。旋转公式为罗德里格旋转公式,如公式 1所示:
V r o t = c o s Θ v + ( 1 − c o s Θ ) ( v ⋅ k ) k + s i n Θ k × v (1) V_{rot}=cos\Theta v + (1 - cos\Theta)(v \cdot k)k + sin\Theta k \times v \tag{1} Vrot=cosΘv+(1−cosΘ)(v⋅k)k+sinΘk×v(1)
在此基础上,只需要将坐标系旋转到头部的位姿状态,根据眼睛的相对位置信息,便可以轻松求得眼睛在母坐标系下的位置。代码如下:
main.cpp
224-232 //计算欧拉角体系下的头部坐标系
headAngTrans.init();
headAngTrans.rotate(VERTEX(0,0,1),-headAng);
headAngTrans.rotate(VERTEX(0,1,0),-headAtt);
headAngTrans.rotate(VERTEX(1,0,0),headRol);
headAngTrans.rotate(VERTEX(0,0,1),-headAngAim);
headAngTrans.rotate(VERTEX(0,1,0),-headAttAim);
headAngTrans.rotate(VERTEX(1,0,0),headRolAim);
vertex at = headAngTrans.get(VERTEX(1,0,0));
vertex up = headAngTrans.get(VERTEX(0,0,1));
63-74 //在坐标系上通过偏移量计算左右眼位置
trans = headAngTrans;
vertex up = trans.get(VERTEX(eyePosL,-eyePosW*0.5,eyePosH));
eyer = MGLTools::add(VERTEX(headx,heady,headz),up);
up = trans.get(VERTEX(eyePosL,eyePosW*0.5,eyePosH));
eyel = MGLTools::add(VERTEX(headx,heady,headz),up);
利用openGL的glViewPort功能可以对窗口进行分区域渲染,具体代码如下:
main.cpp
void display()//绘制回调函数
{
calEyePos();//计算眼睛位置
displayInit();//初始化绘制
glViewport(0, 0, widthF / 2, heightF);//圈定左侧为绘画区域
cam.setPos(leftx,lefty,leftz);//设置摄像机到左眼位置
cam.setDirection(eyeAt,eyeUp);//设置摄像机到左眼角度
displayEye(0);//绘制
glViewport(widthF / 2, 0, widthF / 2, heightF);//同理绘制右侧
cam.setPos(rightx,righty,rightz);
cam.setDirection(eyeAt,eyeUp);
displayEye(0);
glViewport(0, 0, widthF, heightF);//锁定全画幅
//绘制边框
displayFrame();
displayEnd();
}
在绘制时,首先计算眼睛的位置再开始绘制,绘制过程中,将摄像机分别移动到眼镜的位置,然后选定绘制区域,再进行绘制,即可完成相关功能。
在openGL中并没有对摄像机对象进行封装,也就没办法对视角进行直接操作。因此我在此封装了一个基于openGL的摄像机类。摄像机类的定义如下所示:
camera.h
class camera
{
private:
float x,y,z;//摄像头位置
float minx,maxx,miny,maxy;//位置限定
float ang;//摄像头朝向角(横向)
float attAng;//摄像头攻角(纵向)
float scaleK;//缩放比(滚动)
float rollAng;//摄像头滚转角
int angVertexFlag;//如果为0,则启用角度模式。如果为1启动向量模式。
vertex at;//指向向量
vertex up;//顶向向量
public:
camera();
void init(float xi,float yi,float zi,float angi,float attAngi);//初始化,设定摄像头位置,角度,攻角
void setRange(float minX,float maxX,float minY,float maxY);//设置摄像头的移动范围
void move(char in,float value);//移动摄像头,设定移动模式和值
void spin(char in,float value);//转动摄像头,设定转动模式和值
void scale(float value);//缩放摄像头
void play(float li,float angi);//绘制摄像机相关
void setPos(float xi,float yi,float zi);//设置位置
void setAng(float atti,float angi,float rolli);//设置角度,会使模式变为角度模式。
void setDirection(vertex ati,vertex upi);//设置方向形式的角度,会使模式变为向量模式。
};
这个类中详细定义了摄像机的各个参数,包括摄像机的位置、朝向、角度。对于角度和朝向的定义分为了两种方式。第一种方式是直接使用角度坐标进行定义,另一种方式是使用两个向量分别表示朝向和正方向的模式来定义。利用向量的模式更加便于计算,而利用角度的模式更加直观。
在这个类中的最核心函数就是play()函数,这个函数利用已经设置好的参数可以直接设置摄像机在OpenGL的配置。该函数的定义如下所示:
void camera::play(float li, float angi)//绘制摄像机相关
{
glMatrixMode(GL_PROJECTION);//设置为摄像机矩阵模式
glLoadIdentity();//恢复为单位矩阵
//这里相当于L是瞳孔(快门)尺寸,使用scale调节以改变缩放比例
float l = li;//瞳孔尺寸
float d = (l*0.5)/tan(angi*0.5);//根据角度设置摄像机焦距
if(d<0)return ;//不合理即退出
glFrustum(-l/scaleK,l/scaleK,-l/scaleK,l/scaleK,d,15000);//设置摄像机投影参数,使其拥有立体效果
//以瞳孔(快门)位置为基准旋转
if(angVertexFlag == 0)
{
vertex up;//头顶方向向量
vertexTransform trans;//声明一个基底变换
trans.init();//初始化
trans.rotate(VERTEX(0,0,1),-ang);//延Z轴转动
trans.rotate(VERTEX(0,1,0),-attAng);//延Y轴转动
trans.rotate(VERTEX(1,0,0),rollAng);//延X轴转动
up = trans.get(VERTEX(0,0,1));//得到当前基底下的(0,0,1),即指向头顶的向量
vertex pos,to;
pos.x = x;
pos.y = y;
pos.z = z;
to = trans.get(VERTEX(-d,0,0));//得到当前基底下的朝向向量的反向
pos = MGLTools::add(pos,to);//与位置相加得到摄像机焦点的位置
gluLookAt(pos.x,pos.y,pos.z,x,y,z,up.x,up.y,up.z);//调整摄像机角度
}
else
{
vertex pos,to;
pos.x = x;
pos.y = y;
pos.z = z;
to = MGLTools::mult(at,-1*d);
pos = MGLTools::add(pos,to);//与位置相加得到摄像机焦点的位置
gluLookAt(pos.x,pos.y,pos.z,x,y,z,up.x,up.y,up.z);//调整摄像机角度
}
glMatrixMode(GL_MODELVIEW);//调整为模型绘制模式
glLoadIdentity();//载入单位矩阵
}
其中两个最核心的函数为glFrustum()以及gluLookAt()。
glFrustrum()直接定义了摄像机的各个核心参数。其参数的具体含义如图 2所示:
图2 glFrustum函数的参数意义
可以看到,该函数定义了一个可以具有立体效果显示的摄像机的光学参数。将镜头参数转换为了一套线性的参数并且圈定了绘制范围。
gluLookAt()定义了摄像机的位置以及朝向的点。在程序中通过朝向向量以及正方向向量计算得到目标点的位置。
图3 双目显示展示效果
本模块通过读取摄像头的单目视觉信息识别VR眼镜的位姿信息。笔记本摄像头的性能往往较差,读取速度较慢,读取一次需要至少70ms的时间,然而,系统的运行频率却很高,因此读取笔记本摄像头数据往往会影响程序的帧率。另一方面,单目信息本身不具备6自由度完备性,因此无论是从信标设计还是算法设计,都需要进行相关的调整。因此本模块主要包含三个核心技术:1)摄像头异步图像拉取。2)光信标分布设计。3)光信标识别解算算法。
cvQueryFrame读取摄像头的图像,其速度是受到硬件限制的。换句话说,无论你怎么写代码,cvQueryFrame都有一个反应速度。对于笔记本电脑自带的摄像头,大约是70ms左右。但是有的情况下,即使图像不能及时更新,我们也希望可以快速的读取到一帧,而不是卡在那里等待总线向我们传输新的一帧。图像可以以50ms一次的效率更新(这受限于硬件是没办法的),但是我们希望可以以更高的频率获取图像并且进行处理。这种情况往往出现在一些半物理的实时系统中,系统的整体周期并不以图像为核心,因此需要图像无论何时都能短周期读取,而不会影响系统的整体周期。这次在完成一个任务的时候,我就遇到了一个问题,程序的运行周期是严格的20ms,一个周期必须准时运行完毕。但是图像读取到达了40ms左右。因此利用多线程的思路,开发了并行的读取方式,让图像的拉取和图像的处理程序在两个线程里独立完成。
具体思路就是数据处理与从硬件拉取数据两者分开,在两个线程内。从硬件拉取数据的线程,每拉取到一帧新的数据,就储存在数据中转区。数据处理的线程每一个程序运行循环,从数据中转区复制出数据进行使用。数据中转区用线程锁锁住防止两个线程冲突访问。
核心线程函数的定义如下:
locator.cpp
void locator::captureThread(CvCapture *capture,IplImage *pSrcImg,mutex *mut,int *end)
{
while(1)//循环执行
{
IplImage *pic;//创建一个图像指针
if (pic = cvQueryFrame(capture))//指针指向视频流抓取到的图片的位置
{
}
else //如果没有获取图像则报错退出
{
cvReleaseCapture(&capture);
cout << "no image stream!\n";
system("pause");
return ;
}
mut->lock();//锁上进程锁,防止冲突访问
cvCopy(pic,pSrcImg);//将图像里的信息保存到中转图像中。
if(*end)//如果标志位是1的话则关闭线程
{
return ;
}
mut->unlock();//解锁进程锁
}
}
由于识别系统为单目识别系统吗,却希望可以识别6自由度信息,因此光信标的设计应该尽量包含多个自由度。光信标的颜色采用常用的绿色被动光信标,之所以选用绿色是因为在自然光中,绿色的成分是相对较少的,尤其是因为绿色对人眼刺激较大,因此人类生存环境中一般很少有绿色物品,选用绿色是为了让误识别概率更低。之所以选用被动光信标,是由于主动光信标必须配备电源,一方面提升了成本,另一方面提升了复杂度。为了降低总体成本以及复杂程度,采用了被动式的光信标。(绿色指 0 255 0 的标准纯绿色)
图4 光信标的正视图与立体图
在这种设计下,光信标阵无论进行俯仰、滚转、偏航、位移等任意类型的变换,只要变换不同,形状就会不同。在尺寸已知且输入给程序的情况下,将信标部署在VR眼镜表面,就可以对VR眼镜的姿态进行估算。光信标的几何尺寸需要在程序中更新。详见recongnizer.h中的定义。
VR眼镜部署信标后的图片如图 5所示:
图 5 信标的VR眼镜实装
光信标识别程序主要完成了两个功能,一是对于图像中光信标的识别,二是根据识别到的光信标位置来估算VR眼镜的实际位姿。
对于光信标的识别,主要是首先针对绿色进行二值化,再检测边缘,最终获取大小在阈值范围内的边缘的中心点的坐标,作为识别到的信标点的坐标。在对于绿色进行二值化时,并没有采用传统的颜色距离,而是自定义了一套条件,根据测试在室内具有更好的识别效果。绿色二值化的判断条件代码如下:
if( ((float)g)/r > 1.4 && ((float)g)/b > 1.4 && (r+g+b)/3.0 > 64)//识别绿色的条件
边缘检测以及中心点计算的代码如下所示:
cvFindContours(pSrcImgBin, storage, &contours, sizeof(CvContour), CV_RETR_TREE, CV_CHAIN_APPROX_SIMPLE, cvPoint(0, 0));//边缘检测
cvDrawContours(pResImg, contours, cvScalar(100), cvScalar(100), 1);
//将所有边缘的边框储存起来
vector<CvRect> areaStar;
CvSeq* _contours = contours;
while (NULL != _contours)
{
areaStar.push_back(cvBoundingRect(_contours));
_contours = _contours->h_next;
}
cvReleaseMemStorage(&storage);
vector<CvPoint2D32f> pointDetect;
//定义一些与坐标相关的变量
float minTemp = 255;
float _i = 0;
float _j = 0;
float _s = 0.0;
float _t = 0.0;
float _Subx = 0.0;
float _Suby = 0.0;
const float halfPixel = 0.5;
vector<float> ansXs;
vector<float> ansYs;
//遍历所有检测到的边缘线
for (vector<CvRect>::const_iterator iter = areaStar.begin(); iter != areaStar.end(); iter++)
{
_Subx = iter->x + iter->width*0.5;
_Suby = iter->y + iter->height*0.5;
if(_Subx > 0.05*srcImgSize.width && _Subx < 0.95*srcImgSize.width && _Suby > 0.05*srcImgSize.height && _Suby < 0.95*srcImgSize.height)//如果尺寸符合要求
{
pointDetect.push_back(cvPoint2D32f(_Subx, _Subx));//保存相关点
//std::cout << "右下 x: " << _Subx << " y: " << _Suby << endl;
cv::Point p;
p.x = _Subx;
p.y = _Suby;
ansXs.push_back(_Subx);
ansYs.push_back(_Suby);
cvCircle(pResImg, p, 2, cv::Scalar(0, 0, 255));//绘制相关点
}
}//end for: iter
在此基础上,进行了位姿的识别。识别的方式主要是对实际的信标进行编号,并且对检测到的新标点进行编号遍历,遍历所有的编号可能性,针对每一种可能性,计算其几何特征,判断其与真实信标分布的符合程度。最后选择几何特征最优的编号组合作为识别编号组合。然后通过建模计算(以及一些样条差值估算),获取当前VR眼镜的位姿。
其中最核心的代码是几何特征匹配度计算,其计算代码如下所示:
if ((ansYs[a[0]] < ansYs[a[3]]) &&//首先是硬性几何条件的判断
(ansYs[a[0]] < ansYs[a[4]]) &&
(ansYs[a[0]] < max(ansYs[a[1]], ansYs[a[2]])) &&
(ansYs[a[1]] < ansYs[a[3]]) &&
(ansYs[a[2]] < ansYs[a[4]]) &&
(ansXs[a[1]] < ansXs[a[2]]) &&
(ansXs[a[3]] < ansXs[a[4]]) &&
(ansXs[a[1]] < ansXs[a[0]]) &&
(ansXs[a[0]] < ansXs[a[2]]) &&
(ansYs[a[5]] < ansYs[a[3]]) &&
(ansYs[a[5]] < ansYs[a[4]]) &&
(ansXs[a[5]] < ansXs[a[2]]) &&
(ansXs[a[5]] < ansXs[a[4]]) &&
(ansXs[a[5]] > ansXs[a[1]]) &&
(ansXs[a[5]] > ansXs[a[3]]) &&
(
(ansYs[a[1]] < ansYs[a[2]] && ansXs[a[1]] < ansXs[a[0]] && ansYs[a[0]] < ansYs[a[2]] && ansXs[a[2]] > ansXs[a[3]]) ||
(ansYs[a[1]] > ansYs[a[2]] && ansXs[a[2]] > ansXs[a[0]] && ansYs[a[0]] < ansYs[a[1]] && ansXs[a[1]] < ansXs[a[4]])
)
)
{//计算两个核心匹配度
float s1 = ParallelScaleRate4P(ansXs[a[1]], ansYs[a[1]], ansXs[a[3]], ansYs[a[3]], ansXs[a[2]], ansYs[a[2]], ansXs[a[4]], ansYs[a[4]],1.0);
float s2 = ParallelScaleRate4P(ansXs[a[0]], ansYs[a[0]], (ansXs[a[1]] + ansXs[a[2]]) / 2.0, (ansYs[a[1]] + ansYs[a[2]]) / 2.0,ansXs[a[0]], ansYs[a[0]], (ansXs[a[3]] + ansXs[a[4]]) / 2.0, (ansYs[a[3]] + ansYs[a[4]]) / 2.0,verticalSizeRate);
s1 = s1 + s2;
if (vAns < 0 || (vAns > s1))//如果是目前为止匹配度最高的则记录下来
{
if (printType <= PRTTP_ANSONLY)std::cout << "find one:" << s1 << endl;
vAns = s1;
for (int i = 0; i < 6; i++)
{
pAns[i] = a[i];
if (printType <= PRTTP_ANSONLY)cout << i << ":" << ansXs[pAns[i]] << "," << ansYs[pAns[i]] << endl;
}
}
}//最终获得匹配度最高的组合
程序顺利运行时,其识别效果如下所示:
图6 VR眼镜位姿识别程序运行效果
图7 VR眼镜位姿识别软件界面效果
图 6展示了VR眼镜在摄像头前时,程序识别位姿进行运作的场景。图 7展示了该场景下桌面的显示状况。从主窗口可以看出,视角发生了偏移,看向了VR眼镜朝向的方向。从右下角的辅助窗口可以看出,可以顺利识别并编号所有的光信标,程序可以正常识别VR眼镜的位姿信息。
3D音效是指利用耳机的左右声道的音量差以及相位差,模拟出声音与人在不同3D相对位置下的声音状态。给人以仿真环境下的仿真声音体验。
图7 3D音效
为了实现此功能,本人完成了用于使用openAL播放3D实时音效的封装类,针对openAL的功能进行了简化封装。每个音源只支持一个wav文件的播放,总数也不高,但是接口十分简洁,基本一看就知道怎么用。最多可以播放24路3D音频,根据设定的位置和物理参数计算左右声道的音量。
OpenAL 主要的功能是在来源物体、音效缓冲和收听者中编码。来源物体包含一个指向缓冲区的指标、声音的速度、位置和方向,以及声音强度。收听者物体包含收听者的速度、位置和方向,以及全部声音的整体增益。引擎进行所有必要的计算,如距离衰减、多普勒效应等,从而体现出3D的音效效果。
该类的定义如下所示:
almanager.h
#define MAX_BUFFER_SIZE 24//最多支持24路,可以修改
//---------------------------------------------------------------
//WAV数据类型相关,从而无关化alut,减少外引模块
struct WAVE_Data {//Wav文件数据体模块
char subChunkID[4]; //should contain the word data
long subChunk2Size; //Stores the size of the data block
};
struct WAVE_Format {//wav文件数据参数类型
char subChunkID[4];
long subChunkSize;
short audioFormat;
short numChannels;
long sampleRate;
long byteRate;
short blockAlign;
short bitsPerSample;
};
struct RIFF_Header {//RIFF块标准模型
char chunkID[4];
long chunkSize;//size not including chunkSize or chunkID
char format[4];
};
//----------------------------------------------------------------
class alManager
{
private:
ALCdevice *device;//设备指针
ALCcontext *context;//上下文指针
ALCboolean bEAX;//环境音效扩展(声卡标准)
ALuint buffers[MAX_BUFFER_SIZE+1];//用于储存音频数据的缓存ID
ALuint sources[MAX_BUFFER_SIZE+1];//用于储存音源ID
bool loadWavFile(const string filename, ALuint buffer,ALsizei* size, ALsizei* frequency,ALenum* format);//根据文件名载入wav文件到指定的bufferID当中。
public:
alManager();//构造函数,没有实际作用。(因为在openAL的设定中,只能启动一个OPENAL的上下文,因此在本类的设定中不能重复定义或初始化)
void loadWav2Buffer(ALuint bufferI,const char *fileName);//根据文件名载入指定wav文件,并且绑定到指定ID的buffer和音源。
void setListenerPos(vertex pos,vertex vel,vertex at,vertex up);//设置听者的位置、速度、脸朝向向量以及头顶朝向向量。
void setSourcePos(int sI,vertex pos,vertex vel,vertex at);//设置指定ID音源的位置、速度、朝向向量。
void setSourcePhy(int sI,float Gain,float maxDis,float halfDistance,float rollOff = 1.0);//设置指定ID音源的物理参数:缩放系数、最大传播距离、衰减参数以及半衰参数。实际信号= (GAIN-20*log10(1+ROLLOFF*(距离-半衰距离)/半衰距离))原始信号
void setSourceLoop(int sI,int isLoop);//设置指定ID音源是否循环,默认不循环。
void play(int sI);//播放指定ID的音源
int getSourceId(int sI);//得到指定ID的实际音源ID,懂openAL的朋友可以使用其进行自定义操作
void init();//初始化openAL
void end();//关闭openAL
};
从定义中可以看出,我对openAL的核心功能进行了有效的封装,可以通过调用这个类进行3D声音的物理参数设定以及播放,具体方式如下:
在程序开始时声明一个ALManager类,并且调用init()函数初始化:
alManager al;
al.init();
在需要载入声音时载入声音并且设置基础参数:
al.loadWav2Buffer(0,"summer.wav");
al.play(0);
al.setListenerPos(VERTEX(0,0,0),VERTEX(0,0,0),VERTEX(0,0,0),VERTEX(0,0,0));
al.setSourcePos(0,VERTEX(0,0,20),VERTEX(0,0,0),VERTEX(0,0,1));
al.setSourcePhy(0,1.0,50000,1.0,15);
可以在任何地方调用函数改变参数:
void unKonwn()
{
......
al.setListenerPos(VERTEX(headx,heady,headz),VERTEX(0,0,0),at,up);
......
}
GLUT最初是《OpenGL红皮书(第二版)》中的示例程序。自那以后,GLUT简单、跨平台的特点,使其在各种实际应用中广泛应用。GLUT(以及freeglut)允许用户在众多的平台的创建和管理窗口中OpenGL容器,以及相关的鼠标、键盘和游戏杆功能。Freeglut是由X-联盟许可下发布的开源、免费的GLUT版本。
图8 freeGlut下的软件运行架构
如图 8所示为系统的整体运行流程。通过循环运行进行计算以及渲染实现动画效果。在系统启动时分为三步。第一步是初始化openGL,该步主要是调用openGL的初始化函数,使系统正式开始运行。如果OpenGL启动失败则无法完成后续功能。第二步是链接回调函数。回调函数是指在特殊事件发生时,系统会自动调用的编程者自定义的函数。例如键盘交互、鼠标交互、图形渲染以及周期性运算定时等等功能,全部都是在回调函数中进行的。可以说在glut框架中,回调函数完成了绝大多数主要功能的定义。初始化变量的定义是为了使所有重要变量在进入系统主循环时都有一个合理的初始值,以避免系统在刚开始运行时不稳定,或者崩溃。
在初始阶段过后系统就会进入主循环。主循环主要划分为五个部分。首先是根据当前过山车的状态和时刻绘制图像。第二步是进入空闲回调函数。在绘制渲染结束后,将会进入非渲染时段,这段时间openGL不占用CPU与GPU,系统可以利用这段时间调用空闲回调函数进行车体下一时刻状态的解算。第三步是跳过多余时间。这是由于在系统运行时需要以稳定的帧率进行运行,一是为了系统的仿真步长可以相对稳定,二是为了显示帧率相对稳定。只有这样才能提供稳定、可靠拟真的画面。第五步是发送绘制指令,使循环回到循环之初,从头开始绘制。
在系统终止阶段主要分为三步。第一步是终止绘制循环,使系统停止循环,进入终止流程。第二步是释放内存,清空已经占用的内存并且解除占用,为关闭程序做好准备。第三步是结束程序。
XDisplay是一个用于将手机变为电脑扩展屏幕的商用软件。利用XDisplay可以将电脑渲染好的图像传输至位于VR眼镜中的手机当中,从而显示电脑运算,手机显示的VR图像架构。与此同时,由于XDisplay属于有线通信,因此图像质量稳定、速度高、延迟低,在使用过程中十分方便。
本应用实现了了基于低成本VR架构的过山车设计及与演示程序。以VR架构技术为核心,设计可以读取人工设计的过山车设计文档,并且转换为过山车实景,通过实时渲染仿真可以在VR眼镜演示过山车运动过程的演示程序。该演示程序利用基于openGL的渲染架构实现场景的光效、材质、纹理、消隐等渲染功能。利用数据结构设计的方式,实现人工设计文档的设计与读取。利用架构中的openAL实现3D实时仿真的音效。在此基础上构架整体系统。
过山车的解析是整个程序的开始。一切场景的基础就是对于过山车的解析。过山车不仅仅要可以展示,也要可以进行设计。这就涉及到了过山车数据的数据结构设计问题。在硬盘中储存的过山车设计文件应该易于编辑、易于理解,便于用户对文件内的数据进行编辑,设计自己希望得到的过山车。而储存在程序内存内的数据结构应该易于管理、易于解释成为OpenGL渲染语句,因此应该效率高、符合程序逻辑。在不同的要求下,我们设计了两套数据结构,并且编写了数据结构转换算法。
表4 过山车数据储存结构设计
硬盘文件格式 | 内存数据格式 |
---|---|
下一段轨道偏向角 | 下一个节点X坐标 |
下一段轨道俯仰角 | 下一个节点Y坐标 |
下一段轨道滚转角 | 下一个节点Z坐标 |
下一段轨道长度 | 下一个节点滚转角度 |
下一段轨道正反 | 下一个节点正反 |
下一段轨道行车速度 | 下一个节点的到达时间 |
从表中可以看出,硬盘中的文件格式,以相对坐标作为内容,可以让作者设计一步想一部,在顺序思维的模式下进行简单有序的思考。而在内存数据格式下,一切都是基于绝对坐标系的,这样可以更方便OpenGL进行绘制。
由于车体的结构比较复杂,而OpenGL只是一个渲染环境而不是一个建模软件,无法设计太过复杂的形状。因此针对车体模型,最好的选择是导入外部已有的模型。针对当前的问题,STL格式的模型文件是很适合的一类模型。STL文件格式(stereolithography,光固化立体造型术的缩写)是由3D SYSTEMS 公司于1988 年制定的一个接口协议,是一种为快速原型制造技术服务的三维图形文件格式。STL 文件由多个三角形面片的定义组成,每个三角形面片的定义包括三角形各个定点的三维坐标及三角形面片的法矢量。
这种格式的优点在于数据量小、结构简单,因此可以做到快速读取、快速绘制、快速渲染。但是他的缺点在于无法定义纹理,并且通身只能使用同一个材质。STL格式的具体定义如下所示:
STL文件格式
solid filenamestl //文件路径及文件名
facet normal x y z // 三角面片法向量的3个分量值
outer loop
vertex x y z ∥三角面片第一个顶点的坐标
vertex x y z // 三角面片第二个顶点的坐标
vertex x y z ∥三角面片第三个顶点的坐标
endloop
endfacet // 第一个三角面片定义完毕
……
……
endsolid filenamestl ∥整个文件结束
在本系统中采用开源库MGLTools自带的功能来进行STL文件的读取和绘制。
static void readStl(const char fileName[],model &mod);用于读取.stl格式的模型到内存之中。
static void drawStl(model &mod,float scale);用于绘制stl模型。
车体的模型使用Creo进行建模,建模结果如下图所示:
图9 过山车车体模型
天空的绘制在传统上分为天空盒子、天空背板以及天空半球三种天空绘制模式。天空盒子是指在场景周边绘制一个巨大的立方体,来作为天空的模型。该方法绘制量小,绘制速度快,是一种常见的方式。但是该方法所绘制的天空在不同面的接缝处显示效果不自然,因此在本项目中不被采纳。
天空背板是指在摄像机方向远处绘制一个足够大的平面跟随摄像机运动的天空绘制方式。这种方式避免了上文所提到的边线问题,但是由于只显示一个平面,因此天空的效果会比较死板。在本系统中采用的天空绘制的方法为天空半球体方法。该方法为在场景上方构建一个半球体的模型作为天空的模型。在这种情况下,天空360度无死角,且不死板。但是在这种模型下,很难找到与此匹配的贴图素材,大部分情况下需要对贴图进行映射,而映射的过程会在较高处的位置产生图像的畸变。
图10 天空的绘制流程
在图中可以看到,半球体的球面与正方体的贴图素材被以非线性的形式映射到一起。半球体被分割成有限个四面体进行绘制,通过调整四面体的密度可以调整天空的整体平滑度。在绘制过程中,a,b,c,d为绘制的平面定点,q,w,e,r为绘制的定点的对应的贴图映射点。
运行VRWork\build-vrRollerCoaster-Desktop_Qt_5_7_1_MSVC2013_64bit-Release\release\vrRollerCoaster.exe。
在弹出的命令行中输入"data.csv",导入过山车数据文件。
程序会自动运行。
通过架构各个部分的功能验证以及最后应用的顺利运行,验证了VR架构的可行性以及基本的可用性。但是在功能方面还有很多需要改进的地方。例如VR眼镜位姿识别的准确性、相关模块的可移植性(是否可以运行在更高级的图形显示平台?)等等。
代码需要的环境:
openCV 2.4.9
openAL最新版(2019.1.1为时间节点下最新的,懒得去确认了,这个库也不咋更新)
openGL
freeglut
我使用的IDE:
QT Creator 4.7.0