评论

收藏

采用 GLM 从代码层面理解 OpenGL 坐标系统

游戏开发 游戏开发 发布于:2021-07-17 23:04 | 阅读数:455 | 评论:0

概要
关于 OpengGL 坐标系统,我很喜欢这篇博客,这篇博客有中文版本的。博客英文版讲诉更让人容易理解。接下来要写的这篇博客是以上篇博客为参考,进一步从代码的层面来理解 OpengGL 坐标系统。
下面博客中的代码都可以在 blogsnippet/opengl/lefthand-or-righthand 目录下获取。上篇参考博客中有下面的流程图。这里假设你已经知道了整个流程。图中我标出的红色 glm 函数分别表示,通常用 glm::lookAt 函数创建 view matrix 而透视投影矩阵则用 glm::perspective 创建。下面将会测试这两个函数。让我们开始吧。
DSC0000.png

默认情况下,NDC 是基于左手坐标系
首先要知道当不调用 glDepthRange 修改映射时, OpenGL NDC 是基于左手坐标系的。这就意味着默认时,物体的 z 轴越大,则物体的坐标越远。继续往下看,你将会发现它的用处。下面就写程序来验证一下。先程序运行结果。
DSC0001.png

程序中一会绘制了 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 如下图。
DSC0002.png

参数 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);
}
}
运行截图如下。
DSC0003.png

程序输出如下所示。
DSC0004.png

      
  • 观察运行结果发现,两组相同的坐标(仅仅是 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 ,通过这样设置就可以产生上图的效果,那么表示你就理解了其中的坐标变换。

关注下面的标签,发现更多相似文章