之前看到一篇文章说默认情况下 OpenGL NDC 是基于左手坐标系。我在我之前的博文中也提到过。之后我在想为什么 OpenGL NDC 是基于左手坐标系?真的是基于左手坐标系吗?所以这篇博客就是来找到答案。
再谈 OpenGL 坐标变换(Coordinate Transformation)
红宝书中第五章很清楚的描述了坐标变换,这里的图示也是引用那里,具体内容可以查看红宝书,我在这里仅简单提一下。
在坐标系中指定坐标点,将坐标点连接起来绘制几何形状。绘制复杂的场景时,不同的物体,静态的或是动态的,必然涉及到坐标变换。如下图,用户指定物体的坐标,然后在 user transform 阶段变换物体,之后就由 OpenGl 进行 clipping 和 viewport transform 操作,然后到了 Opengl 管线中光栅化阶段,最终由片段着色器(fragment shader)输出片段颜色,显示在屏幕上。
现代 OpenGL 特地有给用户预留 user transform 这一些阶段。在这一阶段用户自己根据需求进行 scale 、rotate 、translate 、project (缩放,旋转,平移,投影)等操作。如下图在 user transform 中就可以看见我们熟悉的 MVP 变换。MVP 变换分别对应着 model matrix 、view matrix 、projection matrix (model 矩阵,view 矩阵,投影矩阵)。假设只用到顶点着色器(vertex shader)和片段着色器(fragment shader)。在顶点着色器中为 gl_Position 赋值后,user transform 就结束了,接下来 OpenGL 根据输入的顶点,最终在屏幕上绘制出形状。
其中通常用 model matrix 把物体变换到 world coordinate space (世界坐标空间)。通常用 view matrix 再世界坐标空间中的物体变换到 eye/camera coordinate space (眼睛坐标空间或摄像机坐标空间)中。物体间的交互通常在世界坐标空间中计算,这是所有物体共用的坐标空间。而有时会根据需要在摄像机坐标空间中进行计算,比如光照计算。在世界坐标空间中 ,(0, 0, 0) 是坐标系原点。而摄像机坐标空间中,摄像机所在位置则是坐标系的原点位置。
视口变换(Viewport Transformation)
如上图,顶点坐标经过 perspective division(OpenGL divide by w)后,要在标准化设备坐标空间(Normalized Device Coordinates,NDC)中对顶点进行剪裁(clipping)。NDC 坐标空间范围在 X 轴,Y 轴和 Z 轴都是 [-1, 1] 。处于 NDC 范围外的顶点会被忽略掉。经过剪裁,余下的顶点是如何显示在窗口上的呢?这时就涉及到了视口变换。glViewport
和 glDepthRange
用于控制视口变换。glViewport
用于将 NDC 中的 x 和 y 坐标变换到窗口坐标空间中(window coordinate)。窗口坐标 x 表示水平方向的像素,y 表示竖直方向的像素。而 glDepthRange
则把 NDC 的 z 坐标映射到窗口深度坐标(window depth)。窗口深度坐标的范围是 [0, 1] 。
需要调用 glEnable
启用 GL_DEPTH_TEST
深度测试,glDepthRange
设置才会起作用,毕竟默认的渲染顺序是后面的绘制的像素覆盖前面的绘制的像素。开启深度测试后,不同深度值显示的先后顺序则是通过 glDepthFunc
设置。
介绍了坐标变换和视口变换后后,你应该对于物体从你指定坐标到最终显示在屏幕上经历了哪些变换,有了初步的认识。下面就通过问题和小例子来感受下。
问题,NDC Z 轴范围真的是 [-1, 1] ?
蓝宝书和红宝书都提到 NDC Z 轴范围是从 [0, 1] ,但是其他资料文章都是写着 [-1, 1] 。不知道为什么蓝宝书和红宝书出了这么多版本后未修改,应该是在的角度不同吧。先不管这些文字上给的范围,经过上面的介绍,我们完全能计算出 Z 轴的实际范围,于是来在代码中验证吧。
代码中忽略了 MV 变换,仅仅进行投影变换,并且在透视投影和正交投影中分别计算 Z 轴范围。要计算 Z 轴范围,就是在近平面(near plane)和远平面(far plane)各取一点,进行投影变换和透视除法,这时得到的坐标的 z 值就是实际 Z 轴的范围。
注意,默认情况下 glm::perspective
和 glm::ortho
函数中指定的近平面和远平面距离是基于右手坐标系来实现的,并且距离是到原点 (0, 0, 0) 的距离(glm::perspective
有左手坐标系实现)。我们的代码中采用的是默认方式。具体代码如下。
void
test_perspective_proj() {
float neardist = 0.1f, fardist = 100.0f;
glm::vec4 nearpoint(0.0f, 0.0f, -neardist, 1.0f);
glm::vec4 farpoint(0.0f, 0.0f, -fardist, 1.0f);
glm::mat4 persmat = glm::perspective(glm::radians(45.0f), 4.0f/3.0f, neardist, fardist);
glm::vec4 nearpoint_clip = persmat * nearpoint;
glm::vec4 farpoint_clip = persmat * farpoint;
nearpoint_clip /= nearpoint_clip.w;
farpoint_clip /= farpoint_clip.w;
glm::vec3 ndc_near(nearpoint_clip);
glm::vec3 ndc_far(farpoint_clip);
printf("one point in near plane:<%f, %f, %f>\n", ndc_near.x, ndc_near.y, ndc_near.z);
printf("one point in far plane:<%f, %f, %f>\n", ndc_far.x, ndc_far.y, ndc_far.z);
}
void
test_orthographic_proj() {
float neardist = -100.0f, fardist = 100.0f;
glm::vec4 nearpoint(0.0f, 0.0f, -neardist, 1.0f);
glm::vec4 farpoint(0.0f, 0.0f, -fardist, 1.0f);
glm::mat4 persmat = glm::ortho(-4.0f, 4.0f, -3.0f, 3.0f, neardist, fardist);
glm::vec4 nearpoint_clip = persmat * nearpoint;
glm::vec4 farpoint_clip = persmat * farpoint;
nearpoint_clip /= nearpoint_clip.w;
farpoint_clip /= farpoint_clip.w;
glm::vec3 ndc_near(nearpoint_clip);
glm::vec3 ndc_far(farpoint_clip);
printf("one point in near plane:<%f, %f, %f>\n", ndc_near.x, ndc_near.y, ndc_near.z);
printf("one point in far plane:<%f, %f, %f>\n", ndc_far.x, ndc_far.y, ndc_far.z);
}
输出分别如下:
one point in near plane:<0.000000, 0.000000, -1.000000>
one point in far plane:<0.000000, 0.000000, 1.000000>
one point in near plane:<0.000000, 0.000000, -1.000000>
one point in far plane:<0.000000, 0.000000, 1.000000>
由此可知 NDC Z 轴范围确实是 [-1, 1] 。
问题,NDC 是左手坐标系还是右手坐标系?
其实讲到这里,相信你已感觉到没有绝对的左手还是右手坐标系。当不作任何设置时,NDC 是所谓的左手坐标系,即 z 坐标越小显示越靠前。然后通过 glDepthRange
和 glDepthFunc
设置后完全可以产生相反的结果。下面的例子就会说明这一点。
glEnable(GL_DEPTH_TEST);
glUseProgram(_program);
glBindVertexArray(_vao);
// 绘制左边的两个三角形
glDepthRangef(0.0f, 1.0f);
glUniform1f(_xoffset_loc, -0.5f);
glDrawArrays(GL_TRIANGLES, 0, 6);
// 调节映射参数后,再在右边绘制三角形
glDepthRangef(1.0f, 0.0f);
glUniform1f(_xoffset_loc, 0.5f);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
glUseProgram(0);
运行截图如下。红色三角形的 z 坐标是 0.0f ,而绿色三角形的 z 坐标是 0.5f 。在右边,修改映射参数后,绿色三角形就挡住了红色三角形。
最后
完整的示例代码在 blogsnippet/opengl/ndc 中。
其实 OpenGL 提供了灵活的方式处理 Z 轴。以上示例中 glDepthRange
和 glDepthFunc
是一方面。另外也可以在调用 glm::perspective
或 ortho
时设置 near 比 far 大,可以查看效果。因此理解了本质后,看文章或者代码能更好的理解。