采用 GLM 从代码层面理解 OpenGL 坐标系统
2017-01-12 00:00
211 查看
概要
关于 OpengGL 坐标系统,我很喜欢这篇博客,这篇博客有中文版本的。博客英文版讲诉更让人容易理解。接下来要写的这篇博客是以上篇博客为参考,进一步从代码的层面来理解 OpengGL 坐标系统。下面博客中的代码都可以在 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 坐标都是基于左手坐标系。
由于我们在 cpp 中完成了很多功能,shader 就很简单。
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" "}";
由于不需要采用 model matrix ,所以这里只进行 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 ,通过这样设置就可以产生上图的效果,那么表示你就理解了其中的坐标变换。
相关文章推荐
- 学习OpenGLProgrammingGuide7thEdition有感-OpenGl中的全局及局部坐标系统理解
- OpenGL 关于全局固定坐标系统与局部移动坐标系统的理解
- opengl坐标系统及绘图流程理解
- 理解SVG坐标系统和变换: transform属性
- 理解SVG坐标系统和变换: 建立新视窗
- 理解Cocos2d-x坐标系统
- OpenGL开发系统-坐标系统及相应函数
- opengl和osg的坐标系统
- 统一D3D与OpenGL坐标系统
- 3DSMAX和OpenGL坐标系统相反
- OPENGL的坐标系统变换
- opengl中各种坐标的关系的理解
- 橡树OpenGL中的坐标系统
- 读书笔记_深入理解计算机系统_第1章_计算机系统漫游 (代码编译链接详细过程)
- 【学生信息管理系统】模块代码的理解
- Opengl和windows的逻辑坐标到设备坐标转换的理解
- RIL层代码的理解(电话系统)
- UIKit坐标,OpenGL坐标,NodeSpace坐标的一些理解
- 学生信息管理系统-代码理解篇
- 统一D3D与OpenGL坐标系统