Selection Mode拾取
OpenGL内置了一种点击交互的拾取方式,即Select Mode。在Select Mode下,会从鼠标点击的射线方向建立一个小小的视锥体,并对其视锥体进行一次绘制,将绘制到的物体的名字与深度值记录到选择缓存区中,供开发者处理使用。 我们使用的GLAD+GLFW的环境已经不支持select mode了,但还是学习一下理论。下一小节尝试用模板来实现鼠标的拾取~
选择缓冲区
在OpenGL中,有一种叫做HIT Record[击中记录]的数据,保存在选择缓存区Select Buffer里。我们可以通过以下这个函数来设定SelectBuffer,给予一个已经分配好空间的INT数组作为参数:
glSelectBuffer (GLsizei size, GLuint *buffer);
使用示例:
GLuint selectBuffer[100];
glSelectBuffer(100,selectBuffer);
每一个被选中的物体都会在选择缓冲区中留下一条记录,并遵循特定的数据结构————
- 该物体名字的数目
- 该物体被选中区域的最小z值
- 该物体被选中区域的最大z值
- 该物体的名字(可能没有或者多个)
拾取模式
通过glRenderMode()设置,可以选择三种模式:
- GL_RENDER 绘制模式
- GL_SELECT 选择模式
- GL_FEEDBACK 反馈模式
在选择模式下,系统主要要做三件事:
1.根据是去操作的参数生成一个特定的视景体; 2.重新绘制所有图元,但不会绘制到颜色缓存中; 3.系统跟踪有哪些图元绘制到了视景体中,并将结果保存到选择缓存区。
设置视景体/拾取框
x,y即为鼠标的点击位置,width,height为构造视景体的长宽; viewport可以通过
glGetIntegerv(GL_VIEWPORT,vp)
获得,是当前窗口的显示范围参数。void gluPickMatrix(GLdouble x, GLdouble y, GLdouble width, GLdouble height, GLint viewport[4])
名字栈
初始化名字栈:
void glInitNames()
栈操作:
//入栈 - 将name压入名字栈,为接下来绘制的物体创建一个名字; void glPushName(GLuint name) //出栈 - 一个物体绘制结束后,将栈顶名字弹出; void glPopName() //弹栈 - 用name取代栈顶的名字 void glLoadName(GLuint name)
参考:
用模板缓冲储存索引值
模板缓冲通常用于模板测试,能够实现镜面、传送门、描边等等特殊效果,但我们也可以把它当做一个临时渲染目标来使用。 通常模板缓冲的值是8位的,最多可以有256种模板值。对一般的游戏场景物件数目而言恐怕不太够用,但我们的魔方最多6阶整好够用啦。(更多物件的情况可以考虑使用颜色缓冲~) 为了将三维的索引值对应到模板值,我们为索引值规定了一种映射方式。其中0的模板值用于表示没有物体的状态。
uint indexToStencil(int i,int j, int z){
uint result =
i + 6*j + 36*z + 1;
return result;
}
bool stencilToIndex(uint uvalue, glm::ivec3& result){
if( uvalue == 0) return false;
int value = (int)uvalue;
value -= 1;
result.z = value/36;
value = value%36; result.y = value/6;
value = value%6; result.x = value;
return true;
}
规定好模板值后,我们需要在绘制时将它写入模板缓冲。 为了使用模板测试,首先我们要将它启用,并在每次绘制时清空缓冲:
glEnable(GL_STENCIL_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
...
//IN RENDER LOOP
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glStencilOp()
的三个参数表明了模板测试、深度测试是否通过时采取的行为——
- sfail:模板测试失败时采取的行为
- dpfail:模板测试通过但深度测试失败时的行为
- dppass:模板测试和深度测试都通过时的行为
具体行为包括:
GL_KEEP/GL_ZERO/GL_REPLACE/GL_INCR/GL+DECR
等。 当未使用模板测试时,OpenGL默认的行为是glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP)
,至少要改变一个默认模式才能使我们的模板测试起到作用。 在拾取的过程中,我们只希望拾取到最靠近镜头的物体,深度测试已经帮我们完成了这个筛选,只有通过了深度测试的片元才会写入模板值。因此我们设置为glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE)
即可。
另外两个跟模板缓冲相关的函数是glStencilMask()
和glStencilFunc()
。
glStencilMask()可以控制模板缓冲的写入,它的参数会与写入值按位进行与计算。一般我们只使用0x00和0xFF:
- 0x00:每一位都是0,即关闭写入
- 0xFF:每一位都是1,即开启写入
glStencilFunc()含有三个参数,用于设置模板测试通过的方法:
- func:规定何时通过,包括
GL_ALWAYS/GL_NEVER/GL_EQUAL/GL_LEQUAL
等 - ref:对比值/写入值
- mask:同上StencilMask
我们不用模板测试来干预绘制,所以就GL_ALWAYS
写入就好了:
for (int i = 0; i < myCube.n; ++i)
{
for (int j = 0; j < myCube.n; j++) {
for (int k = 0; k < myCube.n; k++) {
glm::mat4 model = glm::mat4(1.0f);
model = myCube.magicCube[i][j][k].matrix *
myCube.magicCube[i][j][k].pos;
mainShader.setMat4("model", model);
glStencilFunc(GL_ALWAYS, indexToStencil(i,j,k), 0xFF);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
}
}
我选择在render loop绘制每个小立方体时直接写入模板值,可以想象大概形成了这样的一副缓冲画面——(虽然模板缓冲并不是一张纹理)
从模板缓冲中采样
GPU阶段在帧缓冲中计算的数据存储位于GPU的显存,我们需要使用glReadPixel()回传到内存。
void glReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, GLvoid* data)
- x,y,width,height共同定义了一个矩形的范围,其中x,y是左下角的屏幕坐标。
- format是读取像素的格式,可以为
GL_STENCIL_INDEX, GL_DEPTH_COMPONENT, GL_DEPTH_STENCIL, GL_RED, GL_GREEN, GL_BLUE, GL_RGB, GL_BGR, GL_RGBA, and GL_BGRA
- type是读取的数据格式,一位数据可以为
GL_INT,GL_FLOAT
等,颜色等多维数据可以是GL_UNSIGNED_SHORT_5_6_5,GL_UNSIGNED_INT_8_8_8_8
等。 - data为保存数据位置的指针
用模板缓冲进行物件拾取的好处是,顶点着色器和深度测试已经帮我们完成了所有空间坐标的转换和深度比较,相机视点到鼠标所点位置的连线如同发出一条选取的射线,所采样到的物件就是第一个击中的物件啦。 (不适用于关闭深度检测的半透明物体!)
每当右键按下时,会对光标所在的位置进行一次数据的回传,如果击中了物件,则记录下方块的索引值,以便筛选旋转层。
void click_callback(GLFWwindow* window, int button, int action, int mods) {
double keyx, keyy;
glfwGetCursorPos(window, &keyx, &keyy);
if (button == GLFW_MOUSE_BUTTON_1) {
...
}
else if(button == GLFW_MOUSE_BUTTON_2){
if(action == GLFW_PRESS){
if(isRotating) return;
//reset picked object
double keyx,keyy;
glfwGetCursorPos(window, &keyx, &keyy);
uint stencil;
glReadPixels(keyx*COORDSCALE, (SCR_HEIGHT-keyy)*COORDSCALE, 1, 1, GL_STENCIL_INDEX, GL_UNSIGNED_INT, &stencil);
if(!stencilToIndex(stencil, myCube.pickedCube)) return;
...
}
else{
...
}
}
}
好像看起来不太对劲呢,纵向转蛮对的,但垂直y轴的 有个小坑—— 众所周知OpenGL的纹理坐标(0,0)是在左下角,但窗口坐标(0,0)是在左上角!怪我对数字太不敏感,也怪我的模型太轴对称了很难意识到这个问题———————— 这个y轴它也是反的啊啊啊! 之前一直只有垂直看的时候,每个方块都能精准读数,一旦有点透视就不准了————因为一旦有点透视它就不沿y对称了哇。。。 所以这里也要(SCR_HEIGHT-keyy)给它翻转一下!
有个大坑!
在写这一段代码的时候笔者使用的是mac系统,屏幕采样时怎么都调不对,de了很久bug发现竟然需要对坐标进行一个二倍的映射,也就是上文中COORDSCALE取2. 去找助教求助时,助教却发现只有在COORDSCALE取1时才能正常采样——
我灵光乍现!等等,似乎之前在创建窗口时,隐约看到一句——
使用视网膜(retina)显示屏的mac用户会发现窗口的width和height都比设置值略微大一些。
所以在glfwGetCursor的时候我们拿到的其实是点坐标而不是像素坐标!苹果系统记得乘二! (这个scale值我就直接在前文#IF APPLE的时候定义好啦)
how to debug
模板检测这里一直不太准,就想看看能不能给它可视化出来——毕竟可视化深度可是很容易的,你模板值和深度值可是存在一块显存里的,应该也可以做到吧—— 但还真就不行!
纠结到夜不能寐,凌晨爬起来检索了半天,找到一个有点麻烦但可以用的方法—— 幸好3阶咱们只有28个模板值,画就画呗。 这个pass在绘制完整个魔方后:
IN RENDER LOOP
//debug
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
debugShader.use();
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glStencilMask(0x00);
for(int i=0;i<myCube.n;i++){
for(int j=0;j<myCube.n;j++){
for(int k=0;k<myCube.n;k++){
glStencilFunc(GL_EQUAL, indexToStencil(i, j, k),0xFF);
debugShader.setInt("stencil", indexToStencil(i, j, k));
glDrawArrays(GL_TRIANGLES, 0, 6);
}
}
}
用到的两个shader就非常简单了,只有在片元着色器里把Stencil转化回vec3,好通过颜色对应上。 (纹理坐标其实也完全用不到,只是copy过来的VAO有,就懒得改了,,)
VERTEX SHADER
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;
out vec2 TexCoords;
void main()
{
TexCoords = aTexCoords;
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
}
FRAGMENT SHADER
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform int stencil;
void main()
{
int value = stencil;
value -= 1;
float scale = 80.0;
float r = value%6/255.0*scale;
value /= 6; float g = value%6/255.0*scale;
value /= 6; float b = value/255.0*scale;
FragColor = vec4(r,g,b,1.0);
}
所以能不能告诉我为啥我已经clear了STENCIL_BUFFER_BIT还会留下之前帧的残影啊.......
***已解决,见🕹️OpenGL丨魔方最终版! - 魔方作业(5)