OpenGL ES教程——你好,三角形

242 阅读8分钟

image.png

三角形绘制就是OpenGL里的 hello world。通过三角形绘制我们快速开始学习吧

本章的目标:学习如何绘制三角形、如何使用顶点着色器、片段着色器

1、代码结构

image.png

后面关于OpenGL的示例,应该都会用这种结构进行演示。

  • SurfaceView,真正显示的控件,值得注意的是,其内部已经封装好了 egl 相关的逻辑,所以示例代码中不需要进行 egl 的配置
  • NativeRender,内部定义了一些jni方法,当GLSurfaceView.Renderer回调时调用这些jni方法
  • RenderContext,和绘制上下文相关,比如把java端的AssetManager传给c++

c++端,除了两个jni文件外,RenderContext是一个单例,它是大总管,负责整体流程。而真正绘制的类为sample类,它有相同的基类,不同子类实现不同的绘制逻辑。

2、顶点坐标系

我们打算绘制的三角形坐标为:

GLfloat vVertices[] = {
        0.0f,  0.5f, 0.0f,
        -0.5f, -0.5f, 0.0f,
        0.5f, -0.5f, 0.0f,
};

肯定有人奇怪,为什么坐标都小于1。这是因为OpenGL为设备的贴心考虑,OpenGL并不知道屏幕的像素有多少,索性定义坐标都在 [-1, 1] 区间,最后计算的时候自己映射真实的坐标点。

这个叫屏幕标准化坐标

image.png

3、顶点着色器

顶点着色器(Vertex Shader)是几个可编程着色器中的一个。如果我们打算做渲染的话,现代OpenGL需要我们至少设置一个顶点和一个片段着色器。

我们需要做的第一件事是用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器,然后编译这个着色器,这样我们就可以在程序中使用它了。

```
#version 300 es
layout(location = 0) in vec4 vPosition;
void main()
{
    gl_Position = vPosition;
}
```

GLSL语言类似于c语言,相信只要看得懂c语言的大差不差也能看懂它。上面的代码中先是定义了一个 vec4 类型的变量,然后在main方法中把它赋值给了全局变量gl_position。

注意in关键字代表着输入变量。它的值是什么?就是我们上面提到的坐标数据,等会就能看到如何给这个变量赋值了

layout(location = 0),相当于变量vPosition加个别名,你的编号是0,以后代码中就可以用0号来跟踪vPosition变量了

为什么这么设计呢?因为上面这段GLSL语句跑在GPU中,我们的代码跑在CPU中,顶点数据也是存在内存里,要通过CPU传给GPU,GPU太傻了,别给它整太复杂的,通过layout方式确定给哪个变量赋值

4、编译着色器

着色器代码就是上面的GLSL代码。示例中,我把它放在asset文件夹中,通过如下方式读取字符串:

char* MyGlRenderContext::getAssetResource(const std::string &path) {
    //manager是jni传入的AAssetManager对象
    auto asset = AAssetManager_open(manager, path.data(), AASSET_MODE_UNKNOWN);
    off_t len = AAsset_getLength(asset);
    char *buffer = new char[len + 1];
    uint32_t num = AAsset_read(asset, buffer, len);
    AAsset_close(asset);
    if (num != len) {
        LOGI("AssetUtil", "loadTextAsset read error");
        delete[] buffer;
        return nullptr;
    }
    buffer[len] = '\0';
    return buffer;
}

GLSL代码是要编译的,很奇怪的设定,但就是要这么干,怎么编译呢?

GLuint GLUtils::loadShader(GLenum shaderType, const char *pSource) {
    LOGI("create shader = %d  source = %s", shaderType, pSource);
    GLuint shader = glCreateShader(shaderType);
    LOGI("create shader = %d", shader);
    if (shader) {
        glShaderSource(shader, 1, &pSource, nullptr);
        glCompileShader(shader);
        GLint compiled = 0;
        glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled);
        if (!compiled) {
            GLint infoLen = 0;
            glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &infoLen);
            if (infoLen) {
                char* buf = new char[infoLen];
                if (buf) {
                    glGetShaderInfoLog(shader, infoLen, nullptr, buf);
                    LOGI("compile shader failed, type = %d, info = %s", shaderType, buf);
                    delete[] buf;
                }
                glDeleteShader(shader);
                shader = 0;
            }
        }
        return shader;
    }
}

看上面的代码,应该很清晰,总共分三步,和把大象装冰箱一样:

  • glCreateShader,创建着色器
  • glShaderSource,指定这个着色器的GLSL代码字符串
  • glCompileShader,编译着色器

glGetShaderiv,则是获取相应的状态,因为GLSL代码很容易写错,不像写java代码一样,又没有提示,通过它能知道到底哪步错了,虽然异常提示也非常非常可怜。。。

5、片段着色器

片段着色器(Fragment Shader)是第二个也是最后一个我们打算创建的用于渲染三角形的着色器。片段着色器所做的是计算像素最后的颜色输出。

#version 300 es
precision mediump float;
out vec4 fragColor;
void main()
{
    fragColor = vec4 ( 1.0, 0.0, 0.0, 1.0 );
}

precision mediump float,用于指定计算精度

out,输出变量,指出要绘制的三角形是什么颜色,本例中是红色。片段着色器的职责就是 计算像素最后的颜色输出,示例中是颜色,可不可以是其它呢?比如图片,也是可以的,说纹理的时候就会说到。

片段着色器的编译同上,只是在创建着色器时,传入的类型不一样,其它代码都一模一样。

6、着色器程序

着色器程序对象(Shader Program Object)是多个着色器合并之后并最终链接完成的版本。如果要使用刚才编译的着色器我们必须把它们链接(Link)为一个着色器程序对象,然后在渲染对象的时候激活这个着色器程序。已激活着色器程序的着色器将在我们发送渲染调用的时候被使用。

GLuint GLUtils::createProgram(const char *pVertexShaderSource, const char *pFragShaderSource,
                              GLuint &vertexShaderHandle, GLuint &fragShaderHandle) {
    GLuint program = 0;
    vertexShaderHandle = loadShader(GL_VERTEX_SHADER, pVertexShaderSource);
    if (!vertexShaderHandle) return program;
    fragShaderHandle = loadShader(GL_FRAGMENT_SHADER, pFragShaderSource);
    if (!fragShaderHandle) return program;

    program = glCreateProgram();
    if (program) {
        glAttachShader(program, vertexShaderHandle);
        checkGlError("glAttachShader");
        glAttachShader(program, fragShaderHandle);
        checkGlError("glAttachShader");

        glLinkProgram(program);
        GLint linkStatus = GL_FALSE;
        glGetProgramiv(program, GL_LINK_STATUS, &linkStatus);

        glDetachShader(program, vertexShaderHandle);
        glDeleteShader(vertexShaderHandle);
        vertexShaderHandle = 0;
        glDetachShader(program, fragShaderHandle);
        glDeleteShader(fragShaderHandle);
        fragShaderHandle = 0;

        if (linkStatus != GL_TRUE) {
            GLint bufLength = 0;
            glGetProgramiv(program, GL_INFO_LOG_LENGTH, &bufLength);
            if (bufLength) {
                char* buf = new char[bufLength];
                LOGI("link program failed info = %s", buf);
                delete[] buf;
            }
            glDeleteProgram(program);
            program = 0;
        }
    }
    LOGI("create program = %d", program);
    return program;
}

代码看起来很长,其实不难理解,关键的代码就几行。

  • glCreateProgram,创建着色器程序
  • glAttachShader,attch之前生成的着色器,包括顶点着色器和片段着色器等
  • glLinkProgram,链接着色器程序

glGetProgramiv,和之前一样,看状态是否正常。

可能又有同学问了,为什么link之后还删除顶点着色、片段着色器呢,它们都还没用到呢。神奇的OpenGL,神奇的状态机,已经link了,detach也没事了,delete也没事了,反正已经link了,它的状态已经保存在 program 这个名字的着色器程序中了

7、链接顶点属性

之前定义的顶点呢,怎么才能传到着色器中来,让GPU去运行着色器代码呢?

image.png 本例中的顶点数据会被这样解析,如上图:

  • 位置数据被储存为32位(4字节)浮点值。
  • 每个位置包含3个这样的值。
  • 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
  • 数据中第一个值在缓冲开始的位置。

代码如下:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vVertices);
glEnableVertexAttribArray(0);
  • 第一个参数指定我们要配置的顶点属性。还记得我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)吗?它可以把顶点属性的位置值设置为0。因为我们希望把数据传递到这一个顶点属性中,所以这里我们传入0
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
  • 下个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子(译注: 这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)。
  • 最后一个参数的类型是vVertices,这就是我们定义的那个坐标数组

8、绘制三角形

终于,我们要绘制三角形了

image.png

void TriangleSample::draw() {
    GLfloat vVertices[] = {
            0.0f,  0.5f, 0.0f,
            -0.5f, -0.5f, 0.0f,
            0.5f, -0.5f, 0.0f,
    };
    if (m_ProgramObj == 0) {
        return;
    }
    glClearColor(1.0, 1.0, 1.0, 1.0);
    glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    glUseProgram(m_ProgramObj);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, vVertices);
    glEnableVertexAttribArray(0);

    glDrawArrays(GL_TRIANGLES, 0, 3);
    glUseProgram(GL_NONE);
}

关键的也就是那么几步:

  • glUseProgram,启用之前定义的着色器程序
  • glVertexAttribPointer,指定顶点数据
  • glEnableVertexAttribArray ,启用顶点数据,它后面跟的参数0,也是之前 layout 定义的location,意思就是传入了数据给GLSL中的vPosition变量了,还要再启用它。OpenGL,真的太难理解了。。。
  • glDrawArrays,绘制指令,第一个参数是我们打算绘制的OpenGL图元的类型。我们希望绘制的是一个三角形,这里传递GL_TRIANGLES给它。第二个参数指定了顶点数组的起始索引,我们这里填0。最后一个参数指定我们打算绘制多少个顶点,这里是3(我们只从我们的数据中渲染一个三角形,它只有3个顶点长)
  • glUseProgram(GL_NONE),状态机思维,要把启用的着色器程序归零

终于讲完了,代码在本人的github中,自取。

最后提一句,本例中并没有处理任何EGL相关的逻辑,那是因为GlSurfaceView已经帮我们处理好了EGL相关的东西,我们不用再处理了,EGL,后面我们再聊