采用 GLM 从代码层面理解 OpenGL 坐标系统
发表于2018-10-16
本篇文章给大家介绍下代码的层面来理解 OpengGL 坐标系统,其中的内容有参考这篇文章https://learnopengl.com/#!Getting-started/Coordinate-Systems,英文版的讲解可能更让人容易理解。
下面文章中的代码都可以在 blogsnippet/opengl/lefthand-or-righthand 目录下获取。这里假设你已经知道了整个流程。图中我标出的红色 glm 函数分别表示,通常用 glm::lookAt 函数创建 view matrix 而透视投影矩阵则用 glm::perspective 创建。下面将会测试这两个函数。让我们开始吧。
默认情况下,NDC 是基于左手坐标系
首先要知道当不调用 glDepthRange 修改映射时, OpenGL NDC 是基于左手坐标系的。这就意味着默认时,物体的 z 轴越大,则物体的坐标越远。继续往下看,你将会发现它的用处。下面就写程序来验证一下。先程序运行结果。
程序中一会绘制了5个图形,左半部分两个,右半部份两个,中间的矩形一个。
点坐标如下:
const GLfloat vertices_leftup[] = { // left up red
-1.0f, -0.5f, -1.0f,
0.0f, -0.5f, -1.0f,
-0.5f, 1.0f, -1.0f,
};
const GLfloat vertices_leftdown[] = { // left down blue
-1.0f, -1.0f, 0.5f,
0.0f, -1.0f, 0.5f,
-0.5f, 0.5f, 0.5f,
};
const GLfloat vertices_rightup[] = { // right up red
0.0f, -0.5f, 0.5f,
1.0f, -0.5f, 0.5f,
0.5f, 1.0f, 0.5f,
};
const GLfloat vertices_rightdown[] = { // right down blue
0.0f, -1.0f, -1.0f,
1.0f, -1.0f, -1.0f,
0.5f, 0.5f, -1.0f,
};
const GLfloat vertices_rect[] = { // center green
-1.0f, -0.5f, 0.0f,
1.0f, -0.5f, 0.0f,
1.0f, 0.5f, 0.0f,
-1.0f, 0.5f, 0.0f,
};
顶点的坐标本身就处于 [-1, 1] 范围内,我们也未做任何坐标变换,因此这些坐标就等于最终的 NDC 坐标值。若 NDC 是基于左手坐标系,则 z 轴上的坐标值越小,就越显示在前面。按照 z 值从小到大排序则是 left up red, right down blue(-1.0f) > center green(0.0f) > left down blue, right up red(0.5f) 。左上角的红色三角形与右下角的蓝色三角形显示在最前面,中间的绿色矩形显示在中间,左下的蓝色三角形和右上的红色三角形显示在最远处。验证了 NDC 是基于左手坐标系。注意,代码中要开启深度测试。
lookAt 参数:
/// Build a look at view matrix based on the default handedness. /// @param eye Position of the camera /// @param center Position where the camera is looking at /// @param up Normalized up vector, how the camera is oriented. Typically (0, 0, 1) template <typename T, precision P> GLM_FUNC_DECL tmat4x4<T, P> lookAt( tvec3<T, P> const & eye, tvec3<T, P> const & center, tvec3<T, P> const & up);
glm::lookAt 可用于产生视图矩阵(view matrix)将 world space 中的点转换到 view/eye space 。函数参数 eye 是 world space 中摄像机的坐标位置,参数 center 是 world space 中摄像机指向的点,up 是指向上方的向量,通常是 (0, 0, 1) 。lookAt 的结果受到是左手坐标系还是右手坐标系的影响,若代码中定义了宏 GLM_LEFT_HANDED 则 glm 调用左手坐标系版本,若没有定义则调用右手坐标系版本,默认是没有定义的,因此默认用的就是右手坐标系版本,glm::perspective 也一样受到此宏的影响。
下面就通过代码来验证 glm::lookAt函数参数中点的位置是 world space 。需求就是计算 world space 某一点 somepoint 到摄像机的距离。有两种计算方式。
- 在 world space 中计算点与摄像机之间的距离。
- 在 view/eye space 中计算点与摄像机之间的距离。
具体代码如下。查看输出结果二者计算的距离是一致的,验证了我们的想法。代码中通过计算 view matrix 的逆矩阵,可以得到摄像机的位置坐标,还蛮有用处的。
void calc_distance_from_camera() { glm::vec4 somepoint(5.0f, 5.0f, 5.0f, 1.0f); glm::vec3 camerapos(2.0f, 2.0f, 2.0f); glm::mat4 viewmat = glm::lookAt(camerapos, glm::vec3(0, 0, 0), glm::vec3(0, 1.0f, 0)); glm::vec4 somepoint_view = viewmat * somepoint; // transform somepoint to view space // 当只有 view matrix 时,也可以计算出摄像机的位置,下面代码就展示了这种计算方式, // 至于原理可以先忽略,涉及到数学的部分总是让人害怕。 glm::mat4 viewmat_inverse = glm::inverse(viewmat); glm::vec3 camerapos_calc(viewmat_inverse[3]); printf("here camera pos: %.2f %.2f, %.2f\n", camerapos.x, camerapos.y, camerapos.z); printf("calc camera pos: %.2f %.2f, %.2f\n", camerapos_calc.x, camerapos_calc.y, camerapos_calc.z); // 计算点 somepoint 距离摄像机的距离 // 方式 1 ,在 world space 中计算距离 float distance_world = glm::distance2(glm::vec3(somepoint), camerapos_calc); printf("distance calc in world space:%.2f\n", distance_world); // 方式 2 ,在 view space 中计算距离, // view space 就是在摄像机位置中观看对象,此时摄像机就相当于原点 glm::vec3 camerapos_view(0, 0, 0); float distance_view = glm::distance2(glm::vec3(somepoint_view), camerapos_view); printf("distance calc in view space:%.2f\n", distance_view); } 输出结果如下: here camera pos: 2.00 2.00, 2.00 calc camera pos: 2.00 2.00, 2.00 distance calc in world space:27.00 distance calc in view space:27.00
perspective参数:
/// Creates a matrix for a symetric perspective-view frustum based on the default handedness. /// @param fovy Specifies the field of view angle in the y direction. Expressed in radians. /// @param aspect Specifies the aspect ratio that determines the field of view in the x direction. The aspect ratio is the ratio of x (width) to y (height). /// @param near Specifies the distance from the viewer to the near clipping plane (always positive). /// @param far Specifies the distance from the viewer to the far clipping plane (always positive). /// @tparam T Value type used to build the matrix. Currently supported: half (not recommanded), float or double. template <typename T> GLM_FUNC_DECL tmat4x4<T, defaultp> perspective(T fovy, T aspect, T near, T far);
glm::perspective用于产生3D透视投影矩阵。实际上这个矩阵定义了一个frustum,位于frustum中的点不会被clip。frustum如下图:
参数 fovy 表示 field of view ,aspect 表示屏幕宽高比,这两个参数都很好理解。 后面的两个参数 near 和 far 定义了 frustum 的近平面和远平面的距离。这是以世界坐标系(world space)为参照坐标系,因此 near 和 far 其实定义的是距离世界坐标系原点 (0, 0, 0) 的距离,从远平面的 4 个点到近平面的相应的 4 个点所在的 4 条直线必然相较于坐标系原点,如上图中的原点位置。我之前就把这里的原点与 view space 中以摄像机为原点搞混淆了,其实在准备透视投影时,就是在 world space 中处理坐标点。
上图发现没,没有指定 z 轴的方向。因为涉及到具体的实现时,z 轴的方向是受左手坐标系还是右手坐标系影响,glm::perspective 同样也是受到宏 GLM_LEFT_HANDED 的控制来决定调用左手坐标系实现还是右手坐标系实现。先不管左手还是右手坐标系,有一点需要记住就是透视投影时,同一个物体,越靠近近平面就显示越靠前也同时比较大,越靠近远平面就显示越靠后同时也较小。这样当在右手坐标系时,z 轴的值越大,则物体越近。当在左手坐标系时,z 轴的值越小,则物体越近。当进行 3D 透视投影时,在 view matrix 转换物体坐标后,落在透视投影矩阵定义的坐标范围外的点就会被剪裁(clip)。
上面提到的左手坐标系和右手坐标系对于开发者来说,就可以根据项目需求来决定物体的坐标是采用右手坐标系指定,还是左手坐标系指定。一般在 OpenGL 的实际项目中大都习惯采用右手坐标系来指定物体的坐标。
举个具体的例子,当仅调用 glm::perspective(glm::radians(45.0f), 800.0f/600.0f, 1.0f, 100.0f); 产生透视投影矩阵进行坐标变换。
- 当采用右手坐标系时,z 轴坐标值范围在 [-1.0f, -100.0f] 。-1.0f 是最近的 z 轴坐标,-100.0f 是最远的 z 轴坐标。
- 当采用左手坐标系时,z 轴坐标值范围在 [1.0f, 100.0f] 。1.0f 是最近的 z 轴坐标,100.0f 是最远的 z 轴坐标。
下面写一段代码来验证上面这个例子。取近平面对应于 NDC 的一个点 (0, 0, -1.0f, 1.0f) 和 远平面对应于 NDC 的一个点 (0, 0, 1.0f, 1.0f) ,逆运算他们对应的 world space 中的点,我们采用右手坐标系,并查看最终的点的 z 轴上的值是不是分别是 -1.0f, -100.0f 。记得前面说的 NDC 是基于左手坐标系,所以对上面取的两个 NDC 坐标不会有疑惑吧。
void
calc_near_far() {
// 采用右手坐标系验证
float neardistance = 1.0f;
float fardistance = 100.0f;
glm::mat4 persmat = glm::perspectiveRH(glm::radians(45.0f), 800.0f/600.0f, neardistance, fardistance);
// NDC 是基于左手坐标系的,近平面对应的 NDC 坐标的 z 轴的值是 -1.0f ,
// 而远平面对应的 NDC 坐标的 z 轴的值是 1.0f 。
glm::vec4 near_ndc(0, 0, -1.0f, 1.0f);
glm::vec4 far_ndc(0, 0, 1.0f, 1.0f);
// 由于我们逆运算这个 ndc 坐标之前所在的世界坐标位置,所以我们要先求逆矩阵。
glm::mat4 inverse_permat = glm::inverse(persmat);
glm::vec4 near_world = inverse_permat * near_ndc;
glm::vec4 far_world = inverse_permat * far_ndc;
printf("before /w: \tnear_world:<%9.3f,%9.3f,%9.3f,%9.3f>\n\t\tfar_world: <%9.3f,%9.3f,%9.3f,%9.3f>\n",
near_world.x, near_world.y, near_world.z, near_world.w,
far_world.x, far_world.y, far_world.z, far_world.w);
// 我们知道 OpenGL 会自动进行透视除法(/w)来将透视矩阵转换后的坐标最终转换成 NDC 。
// 而刚刚乘以逆矩阵只消除了透视投影,未消除透视除法,
// 这时还应该再除以 w 分量,让 w 分量为 1 来消除透视除法的影响。
near_world /= near_world.w;
far_world /= far_world.w;
printf("after /w: \tnear_world:<%9.3f,%9.3f,%9.3f,%9.3f>\n\t\tfar_world: <%9.3f,%9.3f,%9.3f,%9.3f>\n",
near_world.x, near_world.y, near_world.z, near_world.w,
far_world.x, far_world.y, far_world.z, far_world.w);
printf("see, in right-hand the z axis of result match -neardistance and -fardistance\n");
}
输出结果如下:
before /w: near_world:< 0.000, 0.000, -1.000, 1.000>
far_world: < 0.000, 0.000, -1.000, 0.010>
after /w: near_world:< 0.000, 0.000, -1.000, 1.000>
far_world: < 0.000, 0.000, -100.000, 1.000>
see, in right-hand the z axis of result match -neardistance and -fardistance
查看输出结果验证了上面示例的正确性。有一点注意是通过逆矩阵转换后,还是需要手动除以 w 分量,使得 w 分量为 1 才是我们想要的结果,因为**透视除法(perspective division)**是 OpengGL 自动执行的,不包括在透视投影矩阵中。
在 cpp 代码中实现 MVP 坐标变换以及透视除法
通常在学习 OpenGL 时,示例代码都是在 vertex shader 中用矩阵乘以顶点属性坐标后,然后赋值给 gl_Position 。通过直接的方式是看不了具体的坐标值。但是我们可以把这一过程在 cpp 代码中实现,并打印出来假设理解。这样赋值给 gl_Position 的坐标就是最终的 NDC 坐标,因为赋值给 gl_Position 后,OpenGL 虽然会再继续执行透视除法,但此时值是不变的。经过分析后发现是可行的,那就来写代码吧。
- 绘制 6 个三角形,左边 3 个为一组,采用右手坐标系绘制,右边 3 个为一组,采用左手坐标系绘制。并且左边三角形坐标与右边相应的三角形坐标仅仅是 x 轴坐标不同。
- 观察左边一组,比较它们的 z 坐标并查看最终的三角形显示的前后顺序。
- 观察右边一组,比较它们的 z 坐标并查看最终的三角形显示的前后顺序。
- 比较左边与右边 y 和 z 坐标相同的三角形,它们的显示顺序是相反的。
- 查看日志,观察最终的 NDC 坐标都是基于左手坐标系。
const GLchar *vertexcode = "#version 330 core \n" "layout(location = 0) in vec4 pos_modelspace; \n" "out vec4 o_color; \n" "void main() { \n" " gl_Position = pos_modelspace; \n" "}"; const GLchar *fragmentcode = "#version 330 core \n" "uniform vec3 bg; \n" "out vec3 color; \n" "in vec4 o_color; \n" "void main() { \n" " color = bg; \n" "}";
由于不需要采用modelmatrix,所以这里只进行MVP中的VP转换,代码如下。基本原理就是调用glMapBufferRange把buffer的地址映射出来,然后依次修改buffer来对顶点进行坐标变换。
// 位置属性包含 4 个元素 (x, y, z, w) ,直接在这里进行矩阵计算。 void initdraw(int len, GLfloat **multi_vertices, GLuint *boarr, int trianglebytes, const glm::mat4 &view, const glm::mat4 &perspective) { GLfloat *ptr, *startptr; int attrnum = trianglebytes / (sizeof(GLfloat) * VERTEX_POSATTR_NUM); printf("init draw now, [triangle num:%d] [vertex num per triangle:%d]\n", len, attrnum); for (int i = 0; i < len; i++) { printf("init draw with triangle:%d\n", i); GLfloat *triangle = multi_vertices[i]; glBindBuffer(GL_ARRAY_BUFFER, boarr[i]); glBufferData(GL_ARRAY_BUFFER, trianglebytes, NULL, GL_STATIC_DRAW); startptr = ptr = (GLfloat*)glMapBufferRange(GL_ARRAY_BUFFER, 0, trianglebytes, GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT); for (int j = 0; j < attrnum; j++) { GLfloat *vertex = triangle + j * VERTEX_POSATTR_NUM; ptr = startptr + j * VERTEX_POSATTR_NUM; glm::vec4 point(vertex[0], vertex[1], vertex[2], vertex[3]); // 位置属性包含的元素 printf("before point:%d %10.3f,%10.3f,%10.3f,%10.3f\n", j, vertex[0], vertex[1], vertex[2], vertex[3]); point = perspective * view * point; // 坐标转换 printf("after point :%d %10.3f,%10.3f,%10.3f,%10.3f\n", j, point.x, point.y, point.z, point.w); printf("after /w :%d %10.3f,%10.3f,%10.3f\n", j, point.x/point.w, point.y/point.w, point.z/point.w); memcpy(ptr, glm::value_ptr(point), sizeof(point)); } if (glUnmapBuffer(GL_ARRAY_BUFFER) == GL_FALSE) printf("fail to unmap buffer\n"); glBindBuffer(GL_ARRAY_BUFFER, 0); } fflush(stdout); } void startdraw(int len, GLuint *boarr, GLuint bglocation, GLfloat **bgarr) { for (int i = 0; i < len; i++) { glUniform3fv(bglocation, 1, bgarr[i]); glBindBuffer(GL_ARRAY_BUFFER, boarr[i]); glVertexAttribPointer(0, VERTEX_POSATTR_NUM, GL_FLOAT, GL_FALSE, 0, 0); glDrawArrays(GL_TRIANGLES, 0, 3); } }
运行截图如下:
程序输出如下所示:
- 观察运行结果发现,两组相同的坐标(仅仅是 x 坐标不同)因为采用了右手和左手坐标系,左边的三角形显示顺序是与右边相反的。
- 观察程序输出结果,发现采用左手还是右手坐标系,最终的 NDC 坐标都是基于左手坐标系,两组中显示最前的三角形 z 值都是最小的。
有趣的问题
查看运行截图发现左右两边最靠前的三角形,它们似乎在一条水平线上。查看程序输出确实是这样,它们最终的 NDC 的 z 轴坐标是一样的,都是 0.952 。到代码仓库 blogsnippet/opengl/lefthand-or-righthand 目录下查看上例完整的代码,找到左边的 view matrix 代码和右边的 view matrix 代码(这里简单的 view matrix 就没有通过调用 lookAt 产生了)。
viewleft=glm::translate(viewleft,glm::vec3(0.0f,0.0f,-10.0f)); viewright=glm::translate(viewright,glm::vec3(0.0f,0.0f,4.0f));
结合三角形的坐标,如果能明白产生viewleft时,z轴偏移是-10.0f,产生viewright时,z轴偏移是4.0f,通过这样设置就可以产生上图的效果,那么表示你就理解了其中的坐标变换。
来自:https://blog.csdn.net/linuxheik/article/details/81747267