OpenGL实现在三维空间拖拽物体

最近本来想用OpenGL实现一个三维形变平台,但是没想到在鼠标交互这里就遇到了麻烦。OpenGL中并没有很实用的能够处理鼠标拖拽3D物体的办法,而我又不想导入外部的交互框架把程序搞得很冗杂。害,那咋办嘛,只好自己从底层实现这个功能了。


一、环境说明

这次实验环境是根据LearnOpenGL网站(https://learnopengl-cn.github.io/)一步步搭建起来的,包括着色器类、网格类、模型类以及摄像机类,感兴趣的同学可以参考其网站,有很详细的教程。此外除了OpenGL常用库(glew、glad和glut)以外,模型的导入用到Assimp库,而一些数学计算则用到了GLM库(这个库包含了很多方便的向量及矩阵的数学方法,也让我有了许多大胆的想法,先不谈),这些就是实验的基本环境。

作为测试,我导入了三个比较简单的几何模型,包括圆柱、四棱锥以及球,并给他们上了不同的贴图,便于区分。对光照并没有什么特别的要求,能看清就行了,毕竟上面说的这些都不是本次实验的重点,也懒得做过多的说明了。我将模型导入以后的实验场景如下所示:

图1 实验场景演示

二、 获取鼠标的世界坐标

既然我们要实现鼠标拖拽物体,那么最重要也是最难的点就是判断鼠标是否放在3D物体上,我的思路是通过摄像机向鼠标发射一条射线,来检测是否碰到物体(这个会放到下一节中详细讲解)。所以,在那之前自然要先获取鼠标转化到三维空间中的位置,以此才能向它发出射线。

关于鼠标屏幕坐标到三维世界坐标的转化,我自己也思考了一下解决办法。大家(应该)都知道要把三维物体投影到屏幕坐标上,要经过一系列的变换操作,这个可以参考LearnOpenGL中坐标系统那一节(或者我相信任何一本图形学的教材都会有对三维变换的介绍,下面这张图大体说明了变换的整个流程。

图2 LearnOpenGL中三维变换的流程图
因为我觉得这个图比较好的说明了整个过程,所以直接借用了
如果想要更系统地了解,还是建议去原网站地址学习

所以,如果要想得到鼠标的世界坐标,我们只要执行以上变换的逆过程就可以了,例如正常计算裁剪坐标的公式为:V_{clip} = M_{projection}\cdot M_{view}\cdot M_{model}\cdot V_{local},那么由裁剪坐标反向获取世界坐标的公式就是M_{model}\cdot V_{local} =M_{view}^{-1}\cdot M_{projection}^{-1}\cdot V_{clip}(注意M_{model}\cdot V_{local}才是世界坐标,V_{local}只是局部坐标)。但难办在于,前面的变换过程都只是简单的矩阵向量计算而已,逆过程也很容易,而我目前对于最后两步——裁剪坐标转化为标准设备坐标,再映射到屏幕坐标,这个过程并不是很了解,更别说逆过程了,程序中的三维转化也是通过glViewPort自动实现的,所以我暂时放弃了自己计算世界坐标的想法(当然也有部分原因是迫于ddl的压力,瓜子悲)。

幸运的是,经过一番查找资料以后,我发现glut库中有函数可以实现这一过程,只需要你将窗口坐标、模型视图矩阵(Model-View Matrix)、投影矩阵(Projection Matrix)作为参数传入,它就会很“人性化”地帮你计算世界坐标。所以我就打包了一个返回世界坐标的方法,里边调用了gluUnProject这个函数,代码如下。

glm::vec3 getViewPos(int x, int y, glm::mat4 pro, glm::mat4 view)
{
    GLint viewPort[4] = { 0, 0, SCR_WIDTH, SCR_HEIGHT };
    GLdouble modelView[16] = {
        view[0][0], view[0][1], view[0][2], view[0][3],
	view[1][0], view[1][1], view[1][2], view[1][3],
	view[2][0], view[2][1], view[2][2], view[2][3],
	view[3][0], view[3][1], view[3][2], view[3][3]
    };
    GLdouble projection[16] = {
    	pro[0][0], pro[0][1], pro[0][2], pro[0][3],
	pro[1][0], pro[1][1], pro[1][2], pro[1][3],
   	pro[2][0], pro[2][1], pro[2][2], pro[2][3],
	pro[3][0], pro[3][1], pro[3][2], pro[3][3]
    };
    //将glm::mat4类型转化为方法所需的数组类型

    GLfloat win_x, win_y, win_z;
    GLdouble object_x, object_y, object_z;
    int mouse_x = x;
    int mouse_y = y;

    win_x = (float)mouse_x;
    win_y = (float)viewPort[3] - (float)mouse_y - 1.0f;
    glReadBuffer(GL_BACK);
    glReadPixels(mouse_x, int(win_x), 1, 1, GL_DEPTH_COMPONENT, GL_FLOAT, &win_z);
    gluUnProject((GLdouble)win_x, (GLdouble)win_y, (GLdouble)win_z, modelView, projection, viewPort, &object_x, &object_y, &object_z);
    //使用gluUnProject方法将结果传入object_x,object_y,object_z中

    glm::vec3 p = glm::vec3(object_x, object_y, object_z);
    return p;
    //返回vec3类型
}
//转换鼠标位置到3维空间

为了测试效果,我实现了一个画线的函数,在摄像头坐标和获取的鼠标世界坐标间画了一条直线,代码和效果如下。

void drawLine(glm::vec3 startPos, glm::vec3 endPos, Shader shader)
{
    double line_vertices[] = { startPos[0], startPos[1], startPos[2], endPos[0], endPos[1], endPos[2]};

    unsigned lineVAO, lineVBO;
    glGenVertexArrays(1, &lineVAO);
    glGenBuffers(1, &lineVBO);

    glBindVertexArray(lineVAO);

    glBindBuffer(GL_ARRAY_BUFFER, lineVBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(line_vertices), line_vertices, GL_DYNAMIC_DRAW);

    glVertexAttribPointer(0, 3, GL_DOUBLE, GL_FALSE, 3 * sizeof(double), (void*)0);
    glEnableVertexAttribArray(0);

    shader.use();
    glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
    glm::mat4 view = camera.GetViewMatrix();
    shader.setMat4("projection", projection);
    shader.setMat4("view", view);

    glm::mat4 model = glm::mat4(1.0f);
    model = glm::translate(model, glm::vec3(0.0f, 0.0f, 0.0f));
    model = glm::scale(model, glm::vec3(1.0f, 1.0f, 1.0f));
    shader.setMat4("model", model);

    glDrawArrays(GL_LINE_STRIP, 0, 2);

    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glBindVertexArray(0);
}
//绘制直线,opengl经典绘制流程,没什么好说的
    
图3 获取世界坐标效果演示

 这里,细心读代码的同学可能会注意到,我并不是直接绘制从摄像机到鼠标世界坐标的直线,而是从 glm::vec3(camera.Position.x, camera.Position.y - 1.0f, camera.Position.z) 也就是从摄像机下方一点的位置开始。这么做是因为我直接绘制从摄像机到鼠标的直线时惊讶地发现什么也看不见,后来稍微思考了一下觉得这是合理的,因为摄像机本来就会转向鼠标位置,如果直线也从摄像机所在点开始绘制并指向鼠标位置,那么摄像机视线与直线就重合了,镜头里的直线实际上就变成了一个点(相当于什么也看不见)。

图4(网图) 红点瞄准镜
不知道比喻是否恰当,但这个效果有点类似于红点瞄准镜,人在瞄准镜
中只会看到一个红点,但实际上是一条与视线齐平的红色激光线

 

三、判断鼠标是否接触到物体

在开始这个实验前,我在网上也查阅了不少相关的工作,最终决定通过射线检测的方式判断鼠标是否在物体上,是因为这个方法背后有一套简单而优雅的数学解释,只需要你学了高数中空间几何相关的知识,就能够轻松地了解其原理和正确性。所以,我接下来会先解释其原理,然后再给出工程上的实现。

首先,简述我所使用的方法:从摄像机位置向鼠标位置发射一条射线,如果鼠标在模型上,那么射线必然与模型网格有交点,也就是说至少与网格上一个面片有交点,那么只需要判断是否存在一个面片与射线有交点即可。那么,既然要判断与面片的交点,那我们肯定得先判断射线与面片所在平面是否有交点(当然,这个一般情况下是肯定会有的),但是为了之后判断这个点是否在面片上,我们需要将射线与所在平面的交点求出来(来了来了来了,复习一下吧,高数经典例题),方法如下。

假设从c(c_{1},c_{2},c_{3})出发(c即代表摄像机位置),往方向\vec{d}(d_{1},d_{2},d_{3})发出一条直线,那么这个直线的参数方程可以写为\left\{\begin{matrix} x=c_{1}+d_{1}*t\\ y=c_{2}+d_{2}*t\\ z=c_{3}+d_{3}*t\\ \end{matrix}\right.;若存在平面\alpha,上面有一点v(v_{1},v_{2},v_{3}),且知道其法向量为\vec{n}(n_{1},n_{2},n_{3}),那么平面方程可写为n_{1}*(x-v_{1})+n_{2}*(y-v_{2})+n_{3}*(z-v_{3}) = 0,联立两个式子,很容易得到t=\frac{(v_{1}-c_{1})*n_{1}+(v_{2}-c_{2})*n_{2}+(v_{3}-c_{3})*n_{3}}{n_{1}*d_{1}+n_{2}*d_{2}+n_{3}*d_{3}},把这个形式写成向量形式就是t=\frac{(v-c)\cdot\vec{n} }{\vec{d}\cdot \vec{n}},既然参数t已经求出,那么交点坐标也很容易求得,即in=c+t*\vec{d},更清晰的推导过程如下图所示。

图5 参数t及交点坐标推导过程

现在,交点坐标我们已经求出来了,怎么判断交点是否在面片内部呢。这个我们可以通过三角形的一个性质(或者说是所有封闭图形的共性)来判断——从三角形的一个点开始走,其内部的点总是在它的同一侧,如下图所示:

图6 封闭图形性质演示
如图,如果我们从A->B->C->A,那么P点始终在我们的左手边方向

 那么接下来该怎么做就很清楚了,利用三角形的这个性质,再根据右手法则判断,可以知道\vec{PA}\times \vec{AB}\vec{PB}\times \vec{BC}\vec{PC}\times \vec{CA}的方向必然是一致的,参考下图。

图7 封闭图形性质数学表示

好了,原理就讲解到这,接下来展示代码。将上述过程打包在pickingFace函数中,如果网格中存在面片与射线有交点,就返回为true,否则返回false。

bool pickingFace(glm::vec3 d, glm::vec3 cameraPos, glm::vec3 pos)
{
    /*
    首先遍历顶点索引数组,获取每个面片的顶点位置信息
    因为采用的三角网格模型,所以每次遍历3个顶点
    */
    for (int i = 0; i < indices.size(); i+=3)
    {
        glm::vec3 v1 = vertices[indices[i]].Position + pos;
	glm::vec3 v2 = vertices[indices[i + 1]].Position + pos;
        glm::vec3 v3 = vertices[indices[i + 2]].Position + pos;
        /*
        根据索引获取相应的顶点在三维空间的坐标,
        注意因为默认都是模型在(0,0,0)时的坐标,所以都要加上pos,变换到模型所在位置
        */

        glm::vec3 dir1 = v1 - v2;
        glm::vec3 dir2 = v1 - v3;
        glm::vec3 n = glm::normalize(glm::cross(dir1, dir2));
        double t = glm::dot(v1 - cameraPos, n) / glm::dot(n, d);
        if (!(t < 0)) continue;
        /*
        先根据顶点计算任意平面上任意两个不共线的向量,然后通过一次叉乘和一次单位化计算面片的法向量
        根据我们推导的公式计算参数t
        因为我们是右手坐标系,所以如果有交点,t必小于0
        如果不是的话直接跳过本次迭代即可,无需进行后面的计算
        */

        glm::vec3 in = cameraPos + glm::vec3(d.x * t, d.y * t, d.z * t);
        glm::vec3 n_1 = glm::normalize(glm::cross(in - v1, v1 - v2));
        glm::vec3 n_2 = glm::normalize(glm::cross(in - v2, v2 - v3));
        glm::vec3 n_3 = glm::normalize(glm::cross(in - v3, v3 - v1));
        if (!(glm::dot(n_1, n_2) > 0 && glm::dot(n_1, n_3) > 0))           
            continue;
	/*
        先根据参数t计算出交点的坐标
        计算3次法向量,通过点乘判断交点在面片内部还是面片外部,在面片外的话跳过本次迭代
        */

        return true;
        //运行到这里说明这个面片与鼠标有交点,也即网格与鼠标有交点,返回为true即可
    }
    return false;
    //遍历所有面片仍未返回说明这个网格与鼠标无交点,返回false
}
//面片拣选算法

那么,结合前面我们完成的画线函数,目前效果看起来是下面这样的,我们让鼠标碰到时就变为线框模式,看起来效果还不错。

图8 鼠标拣选效果演示

 

四、实现拖拽

鼠标选中物体已经完成,那么实现拖拽其实也轻而易举了,方法是在移动物体的时候,一直保存鼠标x、y方向上移动的差值,这个差值在鼠标移动事件中我们早就计算过了,用于摄像头的转向,不过这会还需要将其保存到一个全局变量中。这样当物体被点击时,按一定速率增加x、y方向的差值(通过乘以一个系数来改变速率),这样就能实现跟随鼠标移动了。不过在处理拖拽以前我们要先解决我们前面遗留下来的小问题,就是两个物体前后距离很近的情况,这会导致鼠标同时碰到两个物体,且两个物体同时被选中。但是也并不难解决,直接比较两个物体和摄像机的距离即可,离得近的那个才是我们想要的被选中的物体。

为了展示完整效果,我特意将一个物体放在另一个物体后面,可以看到,点击时只有前面一个物体被选中了。当然,最终的实现效果仍存在缺陷,就是只能支持物体在x轴和y轴上的移动,如果想要增加z轴可能还需要显示一个局部坐标系(想想就头秃,算了算了,再说吧)。

图9 鼠标拖拽效果演示

 

五、多说两句

我在一开始说过,基础的框架是根据LearnOpenGL的教程一步步搭建起来的,因为代码量有点多,所以我并没有全部展示。能省则省,仅是将核心代码或者说我自己的东西展现出来了,其它部分请参考原学习地址。如果代码阅读依旧存在困难(虽然觉得自己代码已经很整洁了且注释也尽量详细),那不妨将原理和思想理解清楚就好,这部分我自认为也交待的比较清楚,希望对您有所帮助。

 

六、相关文章推荐

https://www.cnblogs.com/graphics/archive/2010/08/05/1793393.html

https://blog.csdn.net/donglei2007/article/details/7868229?utm_medium=distribute.pc_relevant.none-task-blog-title-3&spm=1001.2101.3001.4242

https://blog.csdn.net/abcjennifer/article/details/6688080

https://learnopengl-cn.github.io/01%20Getting%20started/08%20Coordinate%20Systems/

  • 6
    点赞
  • 44
    收藏
    觉得还不错? 一键收藏
  • 9
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值