本篇紧随上篇,探讨一下OpenGL内置的拾取机制,给出一个完整的拾取部分实现代码,请有心人批评指正。前篇是:[乱弹OpenGL选择-拾取机制(上)] —— ZwqXin.com
前篇主要讲了名字栈机制,这是拾取机制中对拾取结果进行识别的机制,是后处理的前提。而本篇则从拾取机制的“拾取”部分,揭露一下我所理解的OpenGL内置拾取机制。
3. 真正的拾取机制
还记得前篇提及的一般游戏引擎中所使用的“射线检测”机制吗?这确实是实现成本非常小的技术,而且无关OPENGL或D3D管道。但是依靠人工的进行点面检测将涉及两个难题:屏幕场景中很多物件怎么办?要把射线与所有这些物件都做一次叉叉检测么;物件由很大量三角形面组成怎么办?连渲染模型本身都够呛了,何况还得在点选时把射线方程与各个面纠缠一次直到检测到或全部检测完发现做了无用功为止……(所以现在都改射线-包围盒检测了~但前一问题尤在。)
OpenGL内置拾取机制利用的是固定渲染管道的东西——视截体裁减。留意过3D图形学的朋友应该记得流水线是怎么“砍掉”场景中不在视野内的部分的,这里我就不探讨其数学原理了——其实也就相交检测之类,不过交给更可靠的流水线去快速实现而已。而这里,OpenGL也是应用的流水线的这个能力,只不过用来“砍”场景的平头视锥题是个“萎缩版”的而已。
(看不到图片的见贴:[显示本站所有图片] )
这是去年9月做的一个小DEMO,场景中有三个模型(物件),能进行鼠标拾取操作从而对模型进行平面移动。如你所见,整个渲染窗口是一个投影平面,也是视锥的近截面。流水线把视锥之外的都喀嚓掉了,譬如船模的船头部分,实际上已经被切断,然后在切断处形成暂时的流水线顶点。那么,我们看鼠标吧。它正在拾取其中一个白色模型,在点击鼠标进行拾取的刹那,在渲染窗口屏幕上形成了一个看不见的小矩形(我在画图工具里用个黄色矩形形象化表示~),这个小矩形就是在拾取时(renderMode == GL_SELECT),OPENGL世界中唯一能显示出来的区域——没错,它就是拾取刹那产生的新平头视截体的近平面!这个新视截体在下图草草表现出来:
注意红色椭圆圈着的就是新视截体的近、远截面,与之间的四条连线构成一个小型平头锥体,是不是形同于意同于“目光”呢?其实也算一种射线吧哈哈。(注意,在屏幕上的小矩形实际上长宽不过10个像素而已,形象化出来的框不代表真实比例的大小。)在拾取阶段,小锥体外的东西都被裁掉了。OPENGL世界里只剩下黄色框内的东西会被渲染……该部分属于白色模型——对,它被选上了。
怎么生成这个小视截体呢?你已经猜到了。投影矩阵!
既然原来gluPerspective设定的投影矩阵来把视图空间下的坐标转为投影空间下的坐标表示,同时形成一个视截体描述[乱弹OpenGL中的矩阵变换(上)] ,那么肯定也有另一个投影矩阵能做类似的事情——它在原来投影矩阵功效的基础上,再对三维空间进行裁切。输入这个矩阵的参数肯定包括鼠标的屏幕坐标,目标屏幕矩形(上图的黄色框)的长宽,可能还应该把当前的VIEWPORT给它来准确裁切(VIEWPORT一般在投影之后才给出,所以它没可能预知道)。OpenGL有一个神秘的矩阵设置函数能帮我们完成输入到输出的转换:
void gluPickMatrix(GLdouble x, GLdouble y, GLdouble witdth, GLdouble height, GLint viewport[4]);
参数分别就是OPENGL下鼠标的屏幕窗口坐标(OPENGL以左下角为原点,WINDOWS下以左上角为原点),目标屏幕矩形的长宽和VIEWPORT(包含起点的屏幕窗口坐标,长和宽,当前的VIEWPORT可以由glGetIntegerv(GL_VIEWPORT, viewportbuffer)获得)。目标屏幕矩形的长宽也叫容差,代表选取时可能的误差范围,可以料想,容差越大,误选取的可能性大些;容差越小,选取所需越灵敏,越难一击选中物件。而因为我们在WINDOWS下得到的鼠标位置都是WINDOWS标准的,因此y值要经过一个小转换到OPENGL标准(viewport[3] - mouse.y,前者为窗口屏幕的宽)。看代码:
- void OnLButtonDown(UINT nFlags, CPoint point)
- {
- GLuint selectBuff[512];
- GLint hits, viewport[4];
- glSelectBuffer(512, selectBuff);
- glGetIntegerv(GL_VIEWPORT, viewport);
- glRenderMode(GL_SELECT);
- glInitNames();
- //glPushName(-1);
- int n = 10;
- glMatrixMode(GL_PROJECTION);
- glPushMatrix();
- glLoadIdentity();
- gluPickMatrix((GLdouble) point.x, (GLdouble) (viewport[3] - point.y), n, n, viewport);
- gluPerspective(PersFov,(GLfloat)VB_WIDTH/(GLfloat)VB_HEIGHT,NearSight,FarSight);
- glMatrixMode(GL_MODELVIEW);
- //glLoadIdentity(); NOT NEED
- camera.Look();//IMPORTANTTTTTTTT
- RenderObjects(GL_SELECT);
- glPopMatrix();
- hits = glRenderMode (GL_RENDER);
- .........
这是鼠标左键单击时消息处理代码的前半部分。前面四行本可扔进构造函数和/或初始化函数,为了功能一致性就留在这里了。glRenderMode决定了之前前篇(乱弹OpenGL选择-拾取机制Ⅰ] )所说的两种渲染模式的选择(GL_SELECT和GL_RENDER)。注意,OpenGL拾取机制限定前篇所说的名字栈机制只有在glRenderMode帮助应用程序设定GL_SELECT后才会生效,所以关于Name的函数应该在GL_SELECT标志设定后才弄。
glMatrixMode(GL_PROJECTION)转入投影矩阵的设置。对了,首先在改变OPENGL内部的矩阵形式前,得把当前的非拾取用的矩阵变换设置存储下来[glPushMatrix()],等应用程序返回GL_RENDER正常模式的时候再弹回出来[glPopMatrix()]作为正常渲染使用。而在这PUSH和POP之间,就是OpenGL拾取-选择逻辑了。
重置单位阵后,接连的gluPickMatrix,gluPerspective,然后设定GL_MODELVIEW标志而对模型视图矩阵进行设定——相机的视图变换和RenderObjects内,每物件的渲染函数内可能有的模型变换。RenderObjects(GL_SELECT)类似于上篇中的那段代码。最终问题来了:在代码逻辑上,名字栈是怎么通知应用程序——该物件被选择了的呢?不妨设我们选中的是Object1。另外,不妨展开代码看清楚点:
- //展开代码:
- glPushMatrix();
- glMatrixMode(GL_PROJECTION);
- glLoadIdentity();
- gluPickMatrix((GLdouble) point.x, (GLdouble) (viewport[3] - point.y), n, n, viewport);
- gluPerspective(PersFov,(GLfloat)VB_WIDTH/(GLfloat)VB_HEIGHT,NearSight,FarSight);
- glMatrixMode(GL_MODELVIEW);
- camera.Look();
- //RenderObjects(GL_SELECT);
- glPushName(OBJECT1); //1
- RenderObject1(); //2
- glPopName(); //3
- glPushName(OBJECT2); //4
- RenderObject2(); //5
- glPopName(); //6
- glPushName(OBJECT3); //7
- RenderObject3(); //8
- glPopName(); //9
- //没特别要求就不要了,或者别放在选择逻辑里
- // RenderOtherRelated(); //10
- glPopMatrix();
注意,无论是RenderObject1()还是RenderObject2(),这类函数的核心特性就是通过顶点画三角形。这些顶点是按顺序一批一批地进入渲染管道的,通过T&L,进行顶点处理……也就是说,当第2行(//2)产生的顶点进入流水线时,第5行产生的顶点还跟在后头呢,一般不会干涉。
所以,情况是Object1和它的名字OBJECT1先进入流水线处理,进行坐标变换—— 它们(顶点)先被左乘一个模型矩阵(可能是单位阵也可能是其他,看你RenderObject1里面有无移动旋转缩放类操作了),然后被左乘一个视图矩阵(由相机camera.Look(),实质即gluLookAt设定和操作),然后被左乘投影矩阵(由gluPerspective设定和操作),然后又是被一个投影矩阵gluPickMatrix(又可直接叫拾取矩阵)左乘,而形成(被裁切而剩下)之前所述的小黄框内部分的顶点(或者加上暂时流水线顶点)。因此应用程序找到对应的名字栈中的名字OBJECT1发送到HIT Record中,存储在SelectBuffer中。
好吧,Object2的顶点来了,但是在gluPickMatrix里被冷落了(根据鼠标坐标判断出裁减视锥里没它嘛),同时不会在它那里形成框框,对应的名字没被“记住”,应用程序于是不发送HIT Record了……其他的,类似吧。(提示:对该代码逻辑过程混乱的朋友可以在看完本文后顺便补补OpenGL矩阵知识哦,当然,也是听我乱弹的- -[乱弹OpenGL中的矩阵变换(下)] )
于是你明白了吧,即使名字栈被不断压入弹出,它起指涉作用的也就在处理其对应的Object的顶点的刹那而已,之后它就该为下一位到达流水线的Object负责了。
4. 拾取的后处理
好,最耐人寻味的已经讲完了。说一下处理完拾取逻辑后的事情吧:hits = glRenderMode (GL_RENDER)一句可以返回HIT数,即当次拾取所击中的物件数(可能是OPENGL内部对发送HIT Record的次数进行记数了吧)。
- ........................
- hits = glRenderMode (GL_RENDER);
- //E//glViewport(0,0,VB_WIDTH,VB_HEIGHT);
- //E//glMatrixMode(GL_PROJECTION);
- //E//glLoadIdentity();
- //E//gluPerspective(PersFov,(GLfloat)VB_WIDTH/(GLfloat)VB_HEIGHT,NearSight,FarSight);
- //E//glMatrixMode(GL_MODELVIEW);
- //E//camera.Look();
- int modelselect = 0; //离眼睛最近的物件的名字(ID)
- if (hits >0)
- {
- int n=0; double minz=selectBuff[1];
- for(int i=1;i<hits;i )
- {
- if (selectBuff[1 i*4]<minz) {n=i;minz=selectBuff[1 i*4];}
- }
- modelselect = selectBuff[3 n*4];
- //E// RenderGLScene();//Invalidate(FALSE);
- }
加了//E//的语句其实可要可不要。因为有时候我们会希望每次选择逻辑结束后马上重绘整个场景,而不是等到下个渲染循环(注意,鼠标操作是一种消息中断,期间渲染主循环被暂停了哦)。这样就得额外实施一次主循环里干的事情了,如果场景大,一次渲染的过程长,那么为了保证过度,这样的强制重绘是很有必要的。这时//E//语句是要的
拾取的后处理,取决于你具体想通过拾取来干嘛了。毕竟OpenGL只负责给你做拾取检测,提供被拾取物件的个数,名字,和最近最远的Z-BUFFER增大值(见上篇中HIT Record结构)。譬如这里就返回离眼睛最近的物件的名字(ID)吧~你看,固定每个记录的项数大小为4的HIT Record事后取数据起来就是方便就是好啊。
最后顺带一提,别以为选择模式下render的物体会有可能在屏幕上灵光一闪哦!事实上我是听说只要HIT Record一被发送(或者没得发),流水线就都会舍弃掉眼前这些顶点不再往下处理的……啊,我只是听说,听说。