OpenGL ES教程——坐标系统

682 阅读12分钟

image.png

如果让我们来设计一个3D世界,我们会怎么设计呢?我们带着这个疑问和思考,来学习本章,感觉会更容易理解。

OpenGL的所有坐标其实都是标准设备坐标,即是在[-1.0, 1.0]区间内,最后这些标准设备坐标又会转化成屏幕坐标,坐标的转化也是有一套流程的,这套流程即是OpenGL的坐标系统。整个过程中,一共有5个比较重要的坐标系统:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

1、概述

为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。我们的顶点坐标起始于局部空间(Local Space),在这里它称为局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。下面的这张图展示了整个流程以及各个变换过程做了什么:

image.png

这几个概念非常的抽象,我说说自己的理解。

想象下,自己就是造物主,天地任你挥洒,假设我要造一座山,首先我在自己的右手边捏了一座山,此时山在我的右手边,这就是局部坐标。然后我要把山移到世界的某个角落中去,移过去以后,山就位于世界坐标系中。横看成岭侧成峰,远看高低各不同,这说明观察的方向不同,人眼看到的画面也不同,这就是观察空间。

大家应该都看过火车轨道,感觉远处的火车轨道相交了,这其实就是裁剪空间,人眼看到的范围有限,只能看到自己身前到远处的一片平截头体,其它的地方都看不到。

最后,所有的坐标都要绘制在手机屏幕上,这就是屏幕空间。

本章,大家会接触三个变换矩阵,模型矩阵,观察矩阵,投影矩阵,一定要掌握这三个矩阵的意义。

2、局部空间

如果我们是造物主,要造一座山,山捏好时放在我的右手边,此时山就是在相对于我的局部空间当中。就像我们写代码一样,上一节讲矩阵变换,我们绘制一个矩形并且贴上纹理,最开始时,矩形的中心点在(0, 0)处,后来我们移动它到右下角。我们可以理解为,刚刚开始时,矩形就在局部空间当中,后面被我们移动到右下角,这时它才位于世界坐标当中。因为我们不可能绘制任何一个形状,都会这么清楚地知道它的最终位置,它刚刚开始的位置就是局部空间。

3、世界空间

从局部空间变换到世界空间,这一步通常就是由模型矩阵(model)完成的。

刚刚开始绘制一个形状,我们并不知道它最终位置在哪里,当我们对这个形状进行一系列的旋转,缩放,移动之后,这个形状找到了自己的最终位置,也就是说,这个形状被我们放到了世界坐标当中。记住,移动到世界坐标中,是由model矩阵完成的。

4、观察空间

观察空间经常被人们称之OpenGL的摄像机(Camera)(所以有时也称为摄像机空间(Camera Space)或视觉空间(Eye Space))。观察空间是将世界空间坐标转化为用户视野前方的坐标而产生的结果。因此观察空间就是从摄像机的视角所观察到的空间。而这通常是由一系列的位移和旋转的组合来完成,平移/旋转场景从而使得特定的对象被变换到摄像机的前方。这些组合在一起的变换通常存储在一个观察矩阵(View Matrix)里,它被用来将世界坐标变换到观察空间

5、裁剪空间

在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间(Clip Space)名字的由来。

为了将顶点坐标从观察变换到裁剪空间,我们需要定义一个投影矩阵(Projection Matrix),它指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0)。所有在范围外的坐标不会被映射到在-1.0到1.0的范围之间,所以会被裁剪掉。在上面这个投影矩阵所指定的范围内,坐标(1250, 500, 750)将是不可见的,这是由于它的x坐标超出了范围,它被转化为一个大于1.0的标准化设备坐标,所以被裁剪掉了。

由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为投影(Projection),因为使用投影矩阵能将3D坐标投影(Project)到很容易映射到2D的标准化设备坐标系中。

将观察坐标变换为裁剪坐标的投影矩阵可以为两种不同的形式,每种形式都定义了不同的平截头体。我们可以选择创建一个正射投影矩阵(Orthographic Projection Matrix)或一个透视投影矩阵(Perspective Projection Matrix)。

5.1、正射投影

正射投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度。在使用正射投影矩阵变换至裁剪空间之后处于这个平截头体内的所有坐标将不会被裁剪掉。它的平截头体看起来像一个容器:

image.png

正射投影用的相对较少,缺少真实感。

要创建一个正射投影矩阵,我们可以使用GLM的内置函数glm::ortho

glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
前两个参数指定了平截头体的左右坐标,第三和第四参数指定了平截头体的底部和顶部。
通过这四个参数我们定义了近平面和远平面的大小,
然后第五和第六个参数则定义了近平面和远平面的距离。
这个投影矩阵会将处于这些x,y,z值范围内的坐标变换为标准化设备坐标。

5.2、透视投影

image.png

如上,如果我们看远方的铁轨,远方的铁轨好像相交了。这种效果就是透视了。

OpenGL就是通过透视矩阵来实现这一效果的。OpenGL中每个顶点其实有四个维度的分量,x、y、z、w,w分量的作用就是帮助实现透视效果。

在透视投影中,越远的坐标点w分量值越大,最后在裁剪空间内的每个坐标,都会用它们的x、y、z分量除以w,这也叫透视除法。透视除法将4D空间裁剪空间坐标变换为3D设备标准化坐标。

image.png

顶点坐标的每个分量都会除以它的w分量,距离观察者越远顶点坐标就会越小。这是也是w分量非常重要的另一个原因,它能够帮助我们进行透视投影。

注意:正射投影中,不会改变它的w坐标,所以每个点的w坐标都没有变化,也就不会有透视效果,透视除法也没有用处了。

在GLM中可以这样创建一个透视投影矩阵:

glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);

同样,glm::perspective所做的其实就是创建了一个定义了可视空间的大平截头体,任何在这个平截头体以外的东西最后都不会出现在裁剪空间体积内,并且将会受到裁剪。一个透视平截头体可以被看作一个不均匀形状的箱子,在这个箱子内部的每个坐标都会被映射到裁剪空间上的一个点。下面是一张透视平截头体的图片:

image.png

它的第一个参数定义了fov的值,它表示的是视野(Field of View),并且设置了观察空间的大小。如果想要一个真实的观察效果,它的值通常设置为45.0f,但想要一个末日风格的结果你可以将其设置一个更大的值。第二个参数设置了宽高比,由视口的宽除以高所得。第三和第四个参数设置了平截头体的平面。我们通常设置近距离为0.1f,而远距离设为100.0f。所有在近平面和远平面内且处于平截头体内的顶点都会被渲染。

6、矩阵组合

前面讲了三个非常重要的矩阵,模型,观察以及透视矩阵,任务坐标都会经历这三个矩阵变换到裁剪坐标当中。

image.png

在代码中,我们将这么写:

gl_Position = projection * view * model * a_position;
//因为矩阵要从右往左读,必须先乘模型矩阵,再乘观察矩阵,最后乘透视矩阵

可能会有同学有疑问了,最后怎么是变换到裁剪坐标就结束了,不是要变换到屏幕坐标中吗?

顶点着色器的输出要求所有的顶点都在裁剪空间内,这正是我们刚才使用变换矩阵所做的。OpenGL然后对裁剪坐标执行透视除法从而将它们变换到标准化设备坐标。OpenGL会使用glViewPort内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点(在我们的例子中是一个800x600的屏幕)。这个过程称为视口变换。

7、右手坐标系

image.png

OpenGL中的x、y、z坐标系是如何指向的呢?如上图所示,伸出右手,大姆指朝右,x轴正方向,食指朝上,y轴正方向,中指弯曲90度,z轴正方向(z轴从穿越屏幕指向自己)。

8、进入3D世界

现在我们要弄一个这样的效果:

1672584664499.gif

绘制一个立方体,其实就是绘制6个矩形,这6个矩形拼接,形成立方体,和绘制矩形一样,只不过多了个z坐标。

其次,每个立方体面上都有两个纹理,怎么给每个面上都绘制纹理呢?片段着色器上,我们会给纹理指定纹理坐标的,意味着只要我们输入了纹理坐标,它就会绘制纹理,所以每个面上我们都指定纹理坐标就行了,这样6个面都会绘制纹理了

另外,这个立方体在旋转,还记得上面三大矩阵吗?这个旋转效果明显就是model矩阵实现的,要旋转肯定要指定它的旋转轴,这里它的旋转轴肯定不是z轴的,这个旋转轴是

0.5f, 1.0f, 0.0f

另外,观察矩阵,我们这里设置的是:

view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));

大家可以试试,如果设置成其它值会是什么效果,多试试就知道观察矩阵的意义以及参数的意义了。观察矩阵就相当于指定摄像机的位置,如果太远,立方体就会变小,太近立方体就会很大。

最后,透视矩阵,这里是这样的:

projection = glm::perspective(glm::radians(45.0f), rat, 0.1f, 100.0f);

透视矩阵一般都是这样的,各个值几乎约定俗成是这样,rat值是自己屏幕的宽高比,其它值大家可以试试更改,看看是什么效果。

最后上代码,大家可以参考下(全部源码可到本人github中获取):

#version 300 es
layout(location = 0) in vec4 a_position;
layout(location = 1) in vec2 a_texCoord;
out vec2 v_texCoord;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
    gl_Position = projection * view * model * a_position;
    v_texCoord = vec2(a_texCoord.x, 1.0 - a_texCoord.y);
}
#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 outColor;
uniform sampler2D firId;
uniform sampler2D secId;

void main() {
    //如果第三个值是0.0,它会返回第一个输入;
    //如果是1.0,会返回第二个输入值。
    //0.2会返回80%的第一个输入颜色和20%的第二个输入颜色,即返回两个纹理的混合色。
    outColor = mix(texture(firId, v_texCoord), texture(secId, v_texCoord), 0.2);
}
void CubeSample::prepareData() {
    float vertices[] = {
            -0.5f, -0.5f, -0.5f, 0.0f, 0.0f,
            0.5f, -0.5f, -0.5f, 1.0f, 0.0f,
            0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
            0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
            -0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
            -0.5f, -0.5f, -0.5f, 0.0f, 0.0f,

            -0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
            0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
            0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f, 1.0f,
            -0.5f, 0.5f, 0.5f, 0.0f, 1.0f,
            -0.5f, -0.5f, 0.5f, 0.0f, 0.0f,

            -0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
            -0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
            -0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
            -0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
            -0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
            -0.5f, 0.5f, 0.5f, 1.0f, 0.0f,

            0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
            0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
            0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
            0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
            0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
            0.5f, 0.5f, 0.5f, 1.0f, 0.0f,

            -0.5f, -0.5f, -0.5f, 0.0f, 1.0f,
            0.5f, -0.5f, -0.5f, 1.0f, 1.0f,
            0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
            0.5f, -0.5f, 0.5f, 1.0f, 0.0f,
            -0.5f, -0.5f, 0.5f, 0.0f, 0.0f,
            -0.5f, -0.5f, -0.5f, 0.0f, 1.0f,

            -0.5f, 0.5f, -0.5f, 0.0f, 1.0f,
            0.5f, 0.5f, -0.5f, 1.0f, 1.0f,
            0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
            0.5f, 0.5f, 0.5f, 1.0f, 0.0f,
            -0.5f, 0.5f, 0.5f, 0.0f, 0.0f,
            -0.5f, 0.5f, -0.5f, 0.0f, 1.0f
    };

    glGenVertexArrays(1, &mVao);
    glBindVertexArray(mVao);

    GLuint vbo;
    glGenBuffers(1, &vbo);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glEnableVertexAttribArray(0);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5* sizeof(float), (const void*)0);
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5* sizeof(float), (const void*)(3*sizeof(float)));

    glBindVertexArray(0);
}
void CubeSample::draw() {
    if (m_ProgramObj == 0) {
        return;
    }
    //注释内容放开并修改model矩阵,就可以看到10个矩阵了
//    glm::vec3 cubePositions[] = {
//            glm::vec3( 0.0f,  0.0f,  0.0f),
//            glm::vec3( 2.0f,  5.0f, -15.0f),
//            glm::vec3(-1.5f, -2.2f, -2.5f),
//            glm::vec3(-3.8f, -2.0f, -12.3f),
//            glm::vec3( 2.4f, -0.4f, -3.5f),
//            glm::vec3(-1.7f,  3.0f, -7.5f),
//            glm::vec3( 1.3f, -2.0f, -2.5f),
//            glm::vec3( 1.5f,  2.0f, -2.5f),
//            glm::vec3( 1.5f,  0.2f, -1.5f),
//            glm::vec3(-1.3f,  1.0f, -1.5f)
//    };

    glEnable(GL_DEPTH_TEST);
    glClearColor(1.0f, 1.0f, 1.0f, 1);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    glUseProgram(m_ProgramObj);
    glBindVertexArray(mVao);

    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, mFirstId);

    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, mSecId);

    GLUtils::setUniformValue1i(m_ProgramObj, "firId", 0);
    GLUtils::setUniformValue1i(m_ProgramObj, "secId", 1);

    glm::mat4 model         = glm::mat4(1.0f);
    glm::mat4 view          = glm::mat4(1.0f);
    glm::mat4 projection    = glm::mat4(1.0f);

    degree = degree + 1.0f;

    model = glm::rotate(model, glm::radians(degree), glm::vec3(0.5f, 1.0f, 0.0f));
    view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
    auto rat = MyGlRenderContext::getInstance()->getWidth() * 1.0f / MyGlRenderContext::getInstance()->getHeight();
    projection = glm::perspective(glm::radians(45.0f), rat, 0.1f, 100.0f);

    auto modelLoc = glGetUniformLocation(m_ProgramObj, "model");
    auto viewLoc = glGetUniformLocation(m_ProgramObj, "view");
    auto projectionLoc = glGetUniformLocation(m_ProgramObj, "projection");

    glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
    glUniformMatrix4fv(viewLoc, 1, GL_FALSE, glm::value_ptr(view));
    glUniformMatrix4fv(projectionLoc, 1, GL_FALSE, glm::value_ptr(projection));

//    for (int i = 0; i < 10; ++i) {
//        glm::mat4 model = glm::mat4(1.0f);
//        model = glm::translate(model, cubePositions[i]);
//        model = glm::rotate(model, glm::radians(degree), glm::vec3(1.0f, 0.3f, 0.5f));
//        glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
//        glDrawArrays(GL_TRIANGLES, 0, 36);
//    }
    glDrawArrays(GL_TRIANGLES, 0, 36);
}