OpenGL 编程/对象选择
对于某些应用程序,能够用鼠标点击来选择屏幕上的对象非常重要。如果您有一个具有非平凡投影(如透视投影)的复杂 3D 场景,那么仅根据鼠标指针的 x 和 y 坐标很难确定您点击了哪个对象。幸运的是,OpenGL 具有一些功能可以使这变得容易得多。我们将研究两种有价值的技术;第一个是根据鼠标坐标和深度缓冲区信息找出我们对象空间中的坐标,第二个是使用模板缓冲区对每个像素进行唯一标记,以便我们可以确定它属于哪个对象。
对于这两种技术,我们都需要在绘制完 3D 场景后从帧缓冲区读取信息。我们不需要读取整个帧缓冲区,我们只需要知道我们用鼠标点击的像素是什么。我们可以使用 GLUT 注册一个回调函数,以便在您点击时获取鼠标位置,并使用 glReadPixels()
函数找出该像素是什么。要注册回调函数,请使用
glutMouseFunc(onMouse);
回调函数如下所示
void onMouse(int button, int state, int x, int y) {
if(state != GLUT_DOWN)
return;
window_width = glutGet(GLUT_WINDOW_WIDTH);
window_height = glutGet(GLUT_WINDOW_HEIGHT);
GLbyte color[4];
GLfloat depth;
GLuint index;
glReadPixels(x, window_height - y - 1, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, color);
glReadPixels(x, window_height - y - 1, 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &depth);
glReadPixels(x, window_height - y - 1, 1, 1, GL_STENCIL_INDEX, GL_UNSIGNED_INT, &index);
printf("Clicked on pixel %d, %d, color %02hhx%02hhx%02hhx%02hhx, depth %f, stencil index %u\n",
x, y, color[0], color[1], color[2], color[3], depth, index);
}
glReadPixels()
函数非常简单。前两个参数是像素的 x 和 y 偏移量,但 OpenGL 的 y 坐标与 GLUT 相反。第三个和第四个参数是我们感兴趣区域的宽度和高度。由于我们只需要一个像素,因此我们指定了一个 1×1 的区域。接下来是我们要读取的帧缓冲区的哪个组件。GL_RGBA 读取完整的颜色信息,GL_DEPTH_COMPONENT 读取深度缓冲区的值,GL_STENCIL_INDEX 读取模板缓冲区的值。第六个参数是我们想要以什么格式存储数据。请注意,对于 OpenGL ES 2.0,您可能只能选择一些格式,具体取决于显卡的硬件和驱动程序功能。最后一个是指向我们想要将数据存储到的变量或数组的指针。
当然,只有在启用深度和模板缓冲区时,您才能获得合理的深度或模板信息。您获得的颜色和模板索引值是您所期望的。但是,深度值可能难以解释,尽管您会清楚地看到,对于更靠近摄像机的物体,深度值更小。深度值始终介于 0 到 1 之间,默认情况下,背景的深度值为 1。
练习
- 修改 纹理立方体教程 以便在您点击窗口时读取颜色、深度和模板缓冲区信息。
鼠标指针的 x 和 y 坐标以及深度缓冲区 z 值位于所谓的窗口坐标中,但大多无用。我们想要做的是将它们转换回对象空间坐标,这是我们用来在其中指定顶点坐标的坐标系。要做到这一点,我们需要对窗口坐标应用变换矩阵的逆矩阵。GLM 库为我们提供了一个方便的函数,可以精确地执行我们想要的操作:glm::unProject()
。以下是使用方式,假设您使用用于显示场景的视图和投影矩阵
glm::vec4 viewport = glm::vec4(0, 0, window_width, window_height);
glm::vec3 wincoord = glm::vec3(x, window_height - y - 1, depth);
glm::vec3 objcoord = glm::unProject(wincoord, view, projection, viewport);
printf("Coordinates in object space: %f, %f, %f\n",
objcoord.x, objcoord.y, objcoord.z);
请注意,如果深度值为 1,则坐标将毫无意义,因此您应该检查一下。现在我们知道对象空间坐标了,我们可以尝试找出哪个对象最靠近这些坐标。一个简单的技术是循环遍历所有对象,并检查每个对象的中心到这些坐标的距离。但这可能不会给出精确的匹配,尤其是在对象具有复杂形状的情况下。如果对象位于规则网格上,您可以轻松地将对象坐标转换为网格坐标。如果您使用八叉树或 BSP 来存储您的几何图形,则可以遍历这些数据结构以快速找到您点击的位置。但是,如果您想准确地知道您点击的像素上的哪个对象,并且上述方法不够好,那么您可以尝试在下一节中介绍的模板缓冲区技术。
练习
- 您通常将模型-视图-投影矩阵应用于您绘制的对象。为什么要在
unProjection
中不包括模型矩阵? - 假设您编写了一个使用等轴测投影的 2D 游戏,并且您可以从下到上绘制屏幕而无需使用深度缓冲区。您仍然可以仅使用 x 和 y 坐标使用
glm::unProject()
吗? - 更改纹理立方体教程以渲染至少 10 个较小的立方体。当点击窗口时,找出哪个立方体的中心最靠近您点击的点。
如果您没有将模板缓冲区用于其他任何用途,则可以使用它来保存有关屏幕上对象的的信息。类似于我们在颜色缓冲区中绘制颜色,我们可以在模板缓冲区中绘制数字。首先,确保在对 glutInitDisplayMode()
的调用中添加了 GLUT_STENCIL
。然后,假设我们要绘制十个对象,我们可以通过这种方式将每个对象的编号绘制到模板缓冲区
void onDisplay() {
glClearColor(...);
glClearStencil(0); // this is the default value
glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT|GL_STENCIL_BUFFER_BIT);
/* Any other initialization goes here */
...
/* Enable stencil operations */
glEnable(GL_STENCIL_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
for(int i = 0; i < 10; i++) {
glStencilFunc(GL_ALWAYS, i + 1, -1);
draw_object(i);
}
}
首先,我们清除整个帧缓冲区,并确保模板缓冲区仅包含零。接下来,我们需要启用模板测试,否则不会发生任何事情。我们使用 glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE)
来确保无论何时写入颜色缓冲区(特别是在深度测试成功时),都会写入模板缓冲区,并且我们将用一个固定的新值替换现有值。然后,在绘制对象本身之前,我们将模板函数设置为始终通过模板测试,并将参考值设置为 i + 1
(因为 0 已用于背景)。我们将掩码设置为所有位都启用(您也可以使用 ~0
或 0xff
而不是 -1
,如果您愿意)。从模板缓冲区读取信息时,如果您读取零,则表示您点击了背景,否则表示您点击了某个对象。不要忘记在必要时减去 1。
练习
- 修改纹理立方体示例以写入模板缓冲区。使其能够在您点击立方体时突出显示它们。
以上两种技术简单快速,但可能对您来说不够好。尽管模板缓冲区技术是最准确的,但几乎所有显卡都只支持 8 位模板缓冲区。这意味着您最多只能识别 255 个对象。如果您需要超过 255 个对象,或者已经将模板缓冲区用于其他用途,请考虑使用以下替代方案之一
- 组合来自模板缓冲区、颜色缓冲区和对象坐标的信息可能会为您提供一个独特的解决方案。
- 使用
glScissor()
多次绘制场景,以仅渲染您感兴趣的像素。在每次传递中,您可以从模板缓冲区获取 8 位信息,因此 3 次传递允许您唯一地识别 1600 万个对象。 - 再次绘制场景,但不要使用模板缓冲区,而是为每个对象赋予一个唯一的纯色。这将为您提供每个像素的 24 位甚至 32 位数字。确保您禁用了抗锯齿。