Shader 入门以及在Android上的实践

539 阅读24分钟

背景

如何让通过编程手段在显示器中显示漂亮的图片(动画),这是一个很吸引人的问题。首先我们看一下以下惊艳的图片,它仅由几十行C代码完成,并且不用借助额外的图形三方库。

#include <iostream>
#include <cmath>
#include <cstdlib>
#define DIM 1024
#define DMI (DIM-1)
#define _sq(x) ((x)*(x)) // square
#define _cb(x) abs((x)*(x)*(x))
#define _cr(x) (unsigned char)(pow((x), 1.0/3.0)) // cube root

unsigned char GR(int, int);
unsigned char BL(int, int);
unsigned char RD(int, int);
void pixel_write(int, int);

FILE *fp;

int main() {
  fp = fopen("MathPic.ppm", "wb");
  fprintf(fp, "P6\n%d %d\n255\n", DIM, DIM);
  for (int j = 0; j < DIM; j++)
  for (int i = 0; i < DIM; i++)
  pixel_write(i, j);
  fclose(fp);
  return 0;
}

void pixel_write(int i , int j) {
  static unsigned char color[3];
  color[0] = RD(i, j)&255;
  color[1] = GR(i, j)&255;
  color[2] = BL(i, j)&255;
  fwrite(color, 1, 3, fp);
}

unsigned char RD(int i,int j){
 float x=0,y=0;int k;
 for(k=0;k++<256;){
   float a=x*x-y*y+(i-768.0)/512;y=2*x*y+(j-512.0)/512;
   x=a;
   if(x*x+y*y>4) break;
 }
  return log(k)*47;
}

unsigned char GR(int i,int j){
  float x=0,y=0;int k;
  for(k=0;k++<256;){
    float a=x*x-y*y+(i-768.0)/512;
    y=2*x*y+(j-512.0)/512;
    x=a;
    if(x*x+y*y>4)break;
  }
  return log(k)*47;
}

unsigned char BL(int i,int j){
  float x=0,y=0;int k;
  for(k=0;k++<256;){
    float a=x*x-y*y+(i-768.0)/512;y=2*x*y+(j-512.0)/512;
    x=a;if(x*x+y*y>4)break;
  }
  return 128-log(k)*23;
}

编译以上c程序并运行,会得到一个MathArt.ppm 文件,PPM(Portable Pixel Map)是一种用于存储位图图像的文件格式,里面包含了在1024 x 1024 个像素中每个像素的rgb颜色值,Mac 上支持直接预览,打开就能得到上图如此精美的图片。这一小段代码通常叫做shader。它直接定义了每个位置像素的颜色,然后输出到文件中,如果我们想动态的绘制这个图片(或者我们需要动态地改变图中的颜色等),我们需要把这个shader代码放进一个不断循环的渲染流程当中,这个绘制流程就叫做**“渲染管线”**,下面我们将以OpenGL图形库为例介绍渲染管线的一些关键步骤。

OpenGL 是一个开放标准的图形API,最初由Silicon Graphics公司开发,并于1992年发布。OpenGL的目标是提供一种通用的、可扩展的、跨平台和高性能的图像渲染解决方案,使得程序员可以轻松地创建复杂的3D场景并进行交互式操作。

基本概念

着色器

着色器(Shader)是OpenGL和OpenGL ES中最重要的组件之一。着色器是一段可以在GPU上执行的程序,用于对每个顶点和片元进行处理。着色器通常由两部分组成:一部分是指定着色器输入的数据类型和序列,另一部分则是执行实际计算的代码。

三角形

在OpenGL和OpenGL ES中,所有的图形都是由三角形表示。三角形是一个简单、可预测且高效的方式来描述复杂图形。三角形具有确定的面积,并保证两个相邻的三角形之间不会出现任何空隙或重叠。

纹理

纹理(texture)是指2D或3D图像,它通过映射到三角形表面上来增强视觉效果。纹理可以是颜色图像、灰度图像或任意图像格式。纹理通常用于模拟表面细节,例如木纹或石纹等。

缓冲区对象

缓冲区对象是一种存储图形数据的机制,包括顶点、索引、纹理等数据。使用缓冲区对象可以提高图形渲染性能,并减少CPU与GPU之间的数据传输量。

OpenGL 渲染流程

OpenGL的渲染流程主要包括顶点着色器(Vertex Shader)、图元装配(Primitive Assembly)、几何着色器(Geometry Shader,可选)、光栅化(Rasterization)、片段着色器(Fragment Shader)和逐片段操作(Per-Fragment Operations)等阶段。

以下是详细描述:

  1. 顶点着色器(Vertex Shader):

    • 作用: 顶点着色器主要用于对输入的顶点数据进行处理,通常包括坐标变换、法线变换、颜色插值等。
    • 执行顺序: 在渲染流程的开始阶段,每个顶点都经过顶点着色器的处理。
  2. 图元装配(Primitive Assembly):

    • 作用: 将顶点着色器输出的顶点组装成图元,如点、线、三角形等。
    • 执行顺序: 在顶点着色器处理后,图元装配阶段将相邻的顶点组装成图元。
  3. 几何着色器(Geometry Shader,可选):

    • 作用: 可选的几何着色器可以在图元装配后对图元进行进一步的处理,如生成新的顶点、生成新的图元等。
    • 执行顺序: 如果使用了几何着色器,它将在图元装配后执行。
  4. 光栅化(Rasterization):

    • 作用: 将图元转换为屏幕上的片段(像素)。
    • 执行顺序: 在几何阶段完成后,图元经过光栅化,生成一系列的片段(像素)。
  5. 片段着色器(Fragment Shader):

    • 作用: 片段着色器对每个生成的像素进行处理,包括颜色计算、纹理采样、光照计算等。
    • 执行顺序: 在光栅化后,每个片段经过片段着色器进行处理。
  6. 逐片段操作(Per-Fragment Operations):

    • 作用: 包括深度测试、模板测试等,用于确定最终哪些片段将被保留并写入帧缓冲。
    • 执行顺序: 在片段着色器处理后,根据逐片段操作的结果,决定最终哪些片段将对最终图像产生影响。

注:"片段"和"像素"在图形学中可以用来表示同一概念,尤其在OpenGL和类似的图形渲染管线中。这两个术语有时可以互换使用,具体取决于上下文和使用的图形API。

整个渲染流程的顺序是由图形管线(Graphics Pipeline)定义的,而这些阶段的执行顺序和作用是为了在计算机图形学中实现高效的图形渲染。图形管线将一组 3D 坐标作为输入,并将它们转换为屏幕上的彩色 2D 像素。图形管道可以分为几个步骤,每个步骤都需要前一步的输出作为其输入。所有这些步骤都是高度专业化的(它们具有一个特定的功能)并且可以轻松地并行执行。由于其并行特性,当今的显卡具有数千个小型处理核心,可以快速处理图形管道中的数据。处理核心在 GPU 上为管道的每个步骤运行小程序。这些小程序被称为着色器。

Hello Triangle

在 OpenGL 中,一切都在 3D 空间中,但屏幕或窗口是 2D 像素数组,因此 OpenGL 的大部分工作是将所有 3D 坐标转换为适合屏幕的 2D 像素。将 3D 坐标转换为 2D 像素的过程由图形管道OpenGL的。图形管线可以分为两大部分:第一部分将 3D 坐标转换为 2D 坐标,第二部分将 2D 坐标转换为实际的彩色像素。在本节中,我们将简要讨论图形管道以及如何利用它来创建精美的像素。

其中一些着色器可由开发人员配置,这允许我们编写自己的着色器来替换现有的默认着色器。这使我们能够对管道的特定部分进行更细粒度的控制,并且由于它们在 GPU 上运行,因此还可以为我们节省宝贵的 CPU 时间。着色器写在OpenGL 着色语言(GLSL)。以下蓝色部分代表我们可以注入自己的着色器的部分。

具有着色器阶段的 OpenGL 图形管道


上图中我们传入的三个3D坐标的列表形成了图形管道的输入,被称为"Vertex Data"数组,代表顶点的集合。每个3D坐标集合构成一个顶点(如A顶点),其中的数据表示为顶点属性。为简化,我们假设每个顶点仅包含一个3D位置和一些颜色值。

第一步:顶点着色器接收单个顶点输入,主要用于将3D坐标转换为新的3D坐标,并对顶点属性进行基本处理。

第二步(可选):顶点着色器的输出可传递至几何着色器,后者通过发出新顶点形成新图元,例如在此示例中生成第二个三角形。

第三步:原始组装阶段接收着色器输出的顶点或点集,形成图元,如两个三角形。

第四步:输出传递给光栅化阶段,映射到屏幕像素,生成供片段着色器使用的片段。剪裁提高性能,丢弃视图之外的片段。(

OpenGL中的片段是OpenGL渲染单个像素所需的所有数据)。

第五步:片段着色器计算最终像素颜色,是高级OpenGL效果发生的阶段。通常包含与3D场景相关的数据,用于计算最终像素颜色,例如灯光、阴影和灯光颜色。

第六步: 在确定所有颜色值后,对象经过最终的阿尔法测试和混合阶段。该阶段检查片段的深度和模板值,然后根据这些值判断片段是否在其他对象之前或之后,并作出相应的丢弃处理。

可见,图形管线是一个相当复杂的整体,包含许多可配置的部分。然而,对于几乎所有情况,我们只需要使用顶点和片段着色器(GPU 上没有默认的顶点/片段着色器)。几何着色器是可选的,通常保留其默认着色器。

顶点输入

OpenGL 是一个 3D 图形库,因此我们在 OpenGL 中指定的所有坐标都是 3D 坐标(xyz坐标)。OpenGL 并不简单地将所有3D 坐标转换为屏幕上的 2D 像素;OpenGL 仅在 3D 坐标位于所有 3 个轴(、和)之间-1.0和上的特定范围内时处理 3D 坐标。这个所谓的所有坐标1.0xyz标准化设备坐标范围最终将在屏幕上可见(并且该区域之外的所有坐标都不会)。

float vertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};  

标准化设备坐标 (NDC)

OpenGL采用右手坐标系,顶点坐标要标准化到-1.0到变化1.0。任何超出此范围的坐标都将被丢弃/剪切,并且在屏幕上不可见。下面您可以看到我们在标准化设备坐标中指定的三角形(忽略轴z):

二维标准化设备坐标,如图所示

VBO 定义顶点数据后,我们将其发送到图形管道的第一个阶段:顶点着色器。这包括在 GPU 上创建存储顶点数据的内存、配置 OpenGL 如何解释内存,并指定数据如何发送到显卡。顶点着色器然后处理指定数量的顶点。通过管理顶点缓冲区对象(VBO)在 GPU 内存中存储大量顶点,我们能够一次性向显卡发送大量数据,提高效率。与逐个发送顶点数据相比,这种方式速度更快。

该缓冲区具有与该缓冲区对应的唯一 ID,因此我们可以使用以下命令生成一个具有缓冲区 ID 的缓冲区:

unsigned int VBO;
glGenBuffers(1, &VBO);  

OpenGL有多种类型的缓冲对象,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL 允许我们同时绑定到多个缓冲区,只要它们具有不同的缓冲区类型。我们可以使用以下命令将新创建的缓冲区绑定到GL_ARRAY_BUFFER目标glBindBuffer功能:

glBindBuffer(GL_ARRAY_BUFFER, VBO);  

我们(在GL_ARRAY_BUFFER目标上)进行的任何缓冲区调用都将用于配置当前绑定的缓冲区,即VBO。然后我们可以 将先前定义的顶点数据复制到缓冲区内存中的函数

glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

第一个参数是我们要将数据复制到的缓冲区的类型:当前绑定到GL_ARRAY_BUFFER目标的顶点缓冲区对象。第二个参数指定我们要传递到缓冲区的数据大小(以字节为单位), 第三个参数是我们要发送的实际数据.

第四个参数指定我们希望显卡如何管理给定的数据。这可以采取 3 种形式:

  • GL_STREAM_DRAW:数据仅设置一次,最多被GPU使用几次。
  • GL_STATIC_DRAW:数据仅设置一次并使用多次。
  • GL_DYNAMIC_DRAW:数据变化很大并且使用很多次。

三角形的位置数据不会改变,被大量使用,并且对于每个渲染调用都保持不变,因此其使用类型最好是GL_STATIC_DRAW。例如,如果缓冲区中的数据可能会频繁更改,则GL_DYNAMIC_DRAW使用类型可确保显卡将数据放置在内存中,从而实现更快的写入速度。

顶点着色器

定义着色器

现代 OpenGL 要求如果我们想要进行一些渲染,我们至少需要设置一个顶点和片段着色器。用着色器语言 GLSL(OpenGL 着色语言, 与)编写顶点着色器,然后编译该着色器,以便我们可以在应用程序中使用它。

//最简单的顶点着色器,因为我们没有对输入数据进行任何处理,只是将其转发到着色器的输出
#version 330 core // 版本声明,GLSL版本号需要和OpenGL版本匹配
layout (location = 0) in vec3 aPos; // in vec3 表示输入的是三维向量

void main()
{
    gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

gl_Position变量,该变量位于vec4幕后。将用作顶点着色器的输出。由于我们的输入是大小为 3 的向量,因此我们必须将其转换为大小为 4 的向量。

编译着色器

为了让 OpenGL 使用着色器,它必须在运行时从源代码动态编译它,暂时将顶点着色器的源代码存储在代码文件顶部的 const C 字符串中:

const char *vertexShaderSource = "#version 330 core\n"
    "layout (location = 0) in vec3 aPos;\n"
    "void main()\n"
    "{\n"
    "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
    "}\0";

第一步创建一个着色器对象,同样由 ID 引用。因此,我们将顶点着色器存储为 anunsigned int并使用以下命令创建着色器gl创建着色器:

unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

提供需要创建色器类型作为参数gl创建着色器。由于我们正在创建一个顶点着色器,因此我们传入GL_VERTEX_SHADER。接下来将着色器源代码附加到着色器对象并编译着色器。

glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

第一个参数:glShader源函数将要编译的着色器对象。

第二个参数:指定我们作为源代码传递的字符串数量,这只是一个。

第三个参数是顶点着色器的实际源代码,第四个参数保留为NULL

注:

调用后检查编译是否成功gl编译着色器。检查编译时错误的方法如下:


int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

如果编译失败,我们应该使用以下命令检索错误消息glGetShaderInfoLog并打印错误消息。


if(!success)
{
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

像素着色器

片段着色器是我们要为渲染三角形而创建的第二个也是最后一个着色器。片段着色器主要用于计算像素的颜色输出。

注:计算机图形学中的颜色由 4 个值组成的数组表示:红色、绿色、蓝色和 alpha(不透明度)分量,通常缩写为 RGBA。在 OpenGL 或 GLSL 中定义颜色时,我们将每个分量的强度设置为0.0和之间的值1.0

// 简单的像素着色器,橙色作为输出,其 alpha 值为1.0(1.0完全不透明)
#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
} 

像素着色器只需要一个输出变量,即一个大小为 4 的向量,它定义了我们应该自己计算的最终颜色输出。我们可以使用out关键字声明输出值。

编译片段着色器的过程与顶点着色器类似,不同的是使用 GL_FRAGMENT_SHADER常量作为着色器类型

unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

着色器程序

着色器程序对象是多个着色器组合的最终链接版本。要使用最近编译的着色器,必须关联它们到一个着色器程序对象。将着色器链接到程序时,它将每个着色器的输出链接到下一个着色器的输入。如果输出和输入不匹配,这也是您会收到链接错误的地方。

unsigned int shaderProgram;
shaderProgram = glCreateProgram();

创建一个着色器程序并返回新创建的程序对象的 ID 引用。现在需要将之前编译的着色器附加到程序对象,然后将它们链接到链接程序:

glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram);

注:

就像着色器编译一样,我们也可以检查链接着色器程序是否失败并检索相应的日志。


glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    ...
}

最终,调用来激活它glUseProgram以新创建的程序对象作为其参数:


glUseProgram(shaderProgram);

一旦我们将着色器对象链接到程序对象中,不要忘记删除它们;因为此时不再需要它们了。


glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);  

至此,我们将输入顶点数据发送到 GPU,并指示 GPU 如何在顶点和片段着色器中处理顶点数据。一切似乎很美好,但是仔细想一下,顶点着色器的输入是一个三维向量,但是我们输入到GPU的顶点数据是一个长度为9的数组,顶点着色器是如何知道解析这个数组的?也许你会理所当然地认为三个一组为一个顶点,但是当顶点数据不止位置属性的时候改咋办(虽然我们还没了解除了位置属性之外的属性)。

所以我们需要教GPU如何解析内存中的顶点数据以及将顶点数据链接到顶点着色器的属性。

** 链接顶点属性**

顶点着色器允许我们以顶点属性的形式指定我们想要的任何输入,虽然这提供了很大的灵活性,但这确实意味着我们必须手动指定输入数据的哪一部分进入顶点着色器中的哪个顶点属性。这意味着我们必须在渲染之前指定 OpenGL 如何解释顶点数据。

我们的顶点缓冲区数据只含有位置属性,格式如下:

OpenGL VBO的顶点属性指针设置

  • 位置数据存储为 32 位(4 字节)浮点值。
  • 每个位置由其中 3 个值组成。
  • 每组 3 个值之间没有空格(或其他值)。值为紧密地包装在数组中。
  • 数据中的第一个值位于缓冲区的开头。

第一个参数:确定了配置的顶点属性,使用 layout (location = 0) 将位置顶点属性的位置设置为 0。

第二个参数:表示顶点属性的大小,这里是一个 vec3,由 3 个浮点值组成。

第三个参数:数据类型,指定为 GL_FLOAT,表示这是一个浮点类型的属性。

第四个参数:用于标准化数据,我们这里不需要,因此保持为 GL_FALSE。

第五个参数:是步幅,表示连续顶点属性之间的间隔,设置为浮点数的大小。

最后一个参数是偏移量,指示位置数据在缓冲区中开始的位置,由于位置数据在数组开头,所以设置为 0。

// 0. 拷贝数据到GPU内存
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 然后设置顶点数据指针 (vertex attributes pointers)
// 注意可以有多个VBO对象,换而言之可以从不同VBO取顶点属性,具体从哪个由之前绑定的VBO对象决定
// 由于在第一步绑定了VBO,并且到这一行代码仍然绑定的是该VBO
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
// 2. 激活我们准备用于渲染的程序
glUseProgram(shaderProgram);
// 3. 开始绘制对象
someOpenGLFunctionThatDrawsOurTriangle();   

每次绘制对象都需要重复配置多个顶点属性,当涉及多个属性和多个对象时,这过程变得繁琐。有没有一种方法可以将所有配置状态存储到一个对象中,然后通过简单地绑定该对象来恢复状态呢?

** 顶点数组对象**

顶点数组对象(也称为VAO)可以像顶点缓冲区对象一样进行绑定,并且从该点开始的任何后续顶点属性调用都将存储在 VAO 内。这样做的好处是,在配置顶点属性指针时,您只需调用一次,并且每当我们想要绘制对象时,我们只需绑定相应的 VAO 即可。

VAO(顶点数组对象)如何运行及其在 OpenGL 中存储内容的图像

生成 VAO 的过程与 VBO 类似:


unsigned int VAO;
glGenVertexArrays(1, &VAO);  

要使用 VAO,您只需使用以下命令绑定 VAO(glBindVertexArray)。从那时起,我们应该绑定/配置相应的 VBO 和属性指针,然后取消绑定 VAO 以供以后使用。一旦我们想要绘制一个对象,只需在绘制对象之前将 VAO 与首选设置绑定即可


// 1. 绑定(Vertex Array Object
// 该点开始的任何后续顶点属性调用都将存储在 VAO 内,以供以后使用
glBindVertexArray(VAO);
// 2. 复制顶点数据到GPU中
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 设置属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);  
  
[...]

// 绘制的代码 (在渲染循环中) :: ..
// 4. 渲染对象
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();  

绘制图形

	// render loop (glfwWindowShouldClose 表示当前渲染窗口是不是需要关闭)
    while (!glfwWindowShouldClose(window))
    {
		[...]

        // draw our first triangle
        glUseProgram(shaderProgram);
        // // 因为我们只有一个VAO, 我们无需重复绑定,但是一般规范是这样做。
        glBindVertexArray(VAO);
        glDrawArrays(GL_TRIANGLES, 0, 3); // 
        // glBindVertexArray(0); // 无需解绑

        [...]
    }

glDrawArrays第一个参数传入GL_TRIANGLES表示我们要绘制三角形,第二个参数指定我们要绘制的顶点数组的起始索引;我们将其保留在0。最后一个参数指定我们想要绘制多少个顶点,即3(我们只从数据中渲染 1 个三角形,正好是 3 个顶点长)。

最终,恭喜我们得到漂亮的三角形。

现代 OpenGL 渲染的基本三角形图像

在Android 上的实践

在Android 上需要使用OpenGL需要使用OpenGL ES(OpenGL for Embedded Systems),因为Android设备通常运行在嵌入式系统上,而OpenGL ES是专为嵌入式设备设计的子集。主要的区别在于OpenGL ES经过精简,专注于移动设备和嵌入式系统的图形渲染需求。在Android开发中,通常使用OpenGL ES来实现图形渲染,无论是2D还是3D图形。

Android支持多个版本的OpenGL ES,如OpenGL ES 1.0、2.0、3.0等,我们使用OpenGL ES 2.0 作为实验,如何在Android 端进行渲染管线。需要介绍以下概念:

/** Hold a reference to our GLSurfaceView */
private GLSurfaceView mGLSurfaceView;

GLSurfaceView是一个特殊的视图,它为我们管理OpenGL 表面并将其绘制到 Android 视图系统中。它还添加了许多功能,使 OpenGL 的使用更加容易,包括但不限于:

  • 它为 OpenGL 提供了专用的渲染线程,这样主线程就不会停滞。
  • 它支持连续或按需渲染。
  • 它使用EGL(OpenGL 和底层窗口系统之间的接口)为您处理屏幕设置。

初始化窗口

@Override
public void onCreate(Bundle savedInstanceState)
{
    super.onCreate(savedInstanceState);
 
    mGLSurfaceView = new GLSurfaceView(this);
 
    // 检查系统是否支持 OpenGL ES 2.0.
    final ActivityManager activityManager = (ActivityManager) 	  getSystemService(Context.ACTIVITY_SERVICE);
    final ConfigurationInfo configurationInfo = activityManager.getDeviceConfigurationInfo();
    final boolean supportsEs2 = configurationInfo.reqGlEsVersion >= 0x20000;
 
    if (supportsEs2)
    {
        // 申请一个 OpenGL ES 2.0 上下文.
        mGLSurfaceView.setEGLContextClientVersion(2);
 
        // Set the renderer to our demo renderer, defined below.
        mGLSurfaceView.setRenderer(new CustomRenderer());
    }
    else
    {
        return;
    }
    setContentView(mGLSurfaceView);
}

@Override
protected void onResume()
{
    // 在 Activity 的 onResume() 方法中,必须调用 GLSurfaceView 的 onResume()
    super.onResume();
    mGLSurfaceView.onResume();
}
 
@Override
protected void onPause()
{
    // 在 Activity 的 onPause() 方法中,必须调用 GLSurfaceView 的 onPause()
    super.onPause();
    mGLSurfaceView.onPause();
}

以上代码首先创建一个GLSurfaceView,并根据系统支持的OpenGL ES版本设置相应的上下文,然后将一个自定义的渲染器(CustomRenderer)关联到GLSurfaceView上。CustomRenderer 是一个自定义的渲染器类,实现了 GLSurfaceView.Renderer 接口。这个类通常包含了在OpenGL ES环境下进行图形绘制所需的方法和逻辑, 如果是OpenGL ES 1.0 中,我们会定义一下类

public class CustomRenderer implements GLSurfaceView.Renderer {
	// 构造方法等可能的成员变量和初始化代码

	@Override
	public void onSurfaceCreated(GL10 gl, EGLConfig config) {
    	// 在OpenGL ES上下文被创建时调用,通常用于一次性的初始化操作
	}

	@Override
	public void onDrawFrame(GL10 gl) {
    	// 在每一帧绘制时调用,用于执行实际的绘制操作
	}

	@Override
	public void onSurfaceChanged(GL10 gl, int width, int height) {
    	// 在Surface尺寸变化时调用,例如屏幕旋转
	}
}

但是,当使用 OpenGL ES 2 进行绘制时,我们不使用以上CustomRenderer;相反,我们使用 GLES20 类的静态方法。OpenGL ES 2 中,我们通过指定数字数组来传递要显示的内容。这些数字可以代表位置、颜色或我们需要的任何其他内容。在本节中,我们将显示三个三角形。

// 新的类成员
/** 分别用于存储三个三角形的顶点数据. */
private final FloatBuffer mTriangle1Vertices;
private final FloatBuffer mTriangle2Vertices;
private final FloatBuffer mTriangle3Vertices;
 
/** 定义了每个浮点数的字节数. */
private final int mBytesPerFloat = 4;
 
/**
 * 初始化了三个三角形的顶点数据.每个三角形的数据由一个浮点数数组表示,
 * 其中包含了每个顶点的坐标 (X, Y, Z) 以及颜色 (R, G, B, A)
 */
public CustomRenderer()
{
    final float[] triangle1VerticesData = {
            // X, Y, Z,
            // R, G, B, A
            -0.5f, -0.25f, 0.0f,
            1.0f, 0.0f, 0.0f, 1.0f,
 
            0.5f, -0.25f, 0.0f,
            0.0f, 0.0f, 1.0f, 1.0f,
 
            0.0f, 0.559016994f, 0.0f,
            0.0f, 1.0f, 0.0f, 1.0f};
 
    ...
 
    // 通过 ByteBuffer 分配并初始化了三个 FloatBuffer,并将顶点数据放入对应的 FloatBuffer 中.
    mTriangle1Vertices = ByteBuffer.allocateDirect(triangle1VerticesData.length * mBytesPerFloat)
    .order(ByteOrder.nativeOrder()).asFloatBuffer();
 
    ...
 
    mTriangle1Vertices.put(triangle1VerticesData).position(0);
 
    ...
}

  Android 上使用 Java 进行编码,但 OpenGL ES 2 的底层实现实际上是用 C 编写的。在将数据传递给 OpenGL 之前,需要将其转换为它能够理解的形式。Java 和本机系统可能不会以相同的顺序存储字节,因此使用一组特殊的缓冲区类并创建一个足够大的 ByteBuffer 来容纳数据,并告诉它使用本机字节顺序存储数据。然后将其转换为 FloatBuffer,以便我们可以用它来保存浮点数据。最后,将数组复制到缓冲区中。

设置视图矩阵

第二步是设置视图矩阵, OpenGL 几种不同类型的矩阵,它们都有一些重要的作用:

  1. 模型矩阵。该矩阵用于将模型放置在“世界”的某个位置。例如,如果您有一个汽车模型,并且希望它位于东边 1000 米处,您将使用模型矩阵来执行此操作。
  2. 视图矩阵。该矩阵代表相机。如果我们想查看向东 1000 米的汽车,我们也必须将自己向东移动 1000 米(另一种思考方式是我们保持静止,而世界其他地方移动 1000 米)向西米)。我们使用视图矩阵来做到这一点。
  3. 投影矩阵。由于我们的屏幕是平面的,我们需要进行最后的转换,将我们的视图“投影”到屏幕上并获得漂亮的 3D 视角。这就是投影矩阵的用途。
理解矩阵
// New class definitions
 /**
 * 存储视图矩阵。可以将其视为相机,该矩阵将世界空间转换为视图空间;它将物体相对于我们的视角进行定位。
 */
private float[] mViewMatrix = new float[16];
 
@Override
public void onSurfaceCreated(GL10 glUnused, EGLConfig config)
{
    // 设置背景清除颜色为灰色。
    GLES20.glClearColor(0.5f, 0.5f, 0.5f, 0.5f);
 
    // 将相机放置在原点后面。
    final float eyeX = 0.0f;
    final float eyeY = 0.0f;
    final float eyeZ = 1.5f;
 
    // 我们朝向远处看
    final float lookX = 0.0f;
    final float lookY = 0.0f;
    final float lookZ = -5.0f;
 
    // 设置我们的上向量。这是我们的头部指向的方向,如果我们持有相机的话。
    final float upX = 0.0f;
    final float upY = 1.0f;
    final float upZ = 0.0f;
 
    // 设置视图矩阵。这个矩阵表示相机的位置。
    // 注意:在OpenGL 1中,使用了一个ModelView矩阵,它是模型矩阵和视图矩阵的组合。
    // 在OpenGL 2中,我们可以选择单独跟踪这些矩阵
    Matrix.setLookAtM(mViewMatrix, 0, eyeX, eyeY, eyeZ, lookX, lookY, lookZ, upX, upY, upZ);

OpenGL 使用右手坐标系,有三个轴:X轴、Y轴和Z轴。以下是关于右手坐标系中X、Y和Z轴的详细解析:

  1. X轴(右向):

    • 正方向:从坐标原点(0, 0, 0)指向正X轴的方向。
    • 特点:在屏幕上通常表示为水平方向,指向右侧。
  2. Y轴(上向):

    • 正方向:从坐标原点(0, 0, 0)指向正Y轴的方向。
    • 特点:在屏幕上通常表示为垂直方向,指向上方。
  3. Z轴(前向):

    • 正方向:从坐标原点(0, 0, 0)指向正Z轴的方向。
    • 特点:在屏幕上通常表示为指向屏幕外的方向,即从您眼睛指向远离您的方向。

这些轴的方向定义了三维空间中的坐标系,其中X和Y轴定义了平面(通常是屏幕上的平面),而Z轴定义了垂直于平面的深度方向。

定义顶点和片段着色器

想要在屏幕上显示的任何内容首先都必须经过顶点和片段着色器。顶点着色器对每个顶点执行操作,这些操作的结果用于片段着色器,片段着色器对每个像素进行额外的计算。

在以下顶点着色器中,我们从顶点着色器获取不同的颜色,然后将其直接传递到 OpenGL。该点已按像素进行插值,因为片段着色器针对将绘制的每个像素。

final String vertexShader =
  "uniform mat4 u_MVPMatrix;      \n"     // 代表组合的模型/视图/投影矩阵的常量。
  + "attribute vec4 a_Position;     \n"   // 我们将传入的每个顶点的位置信息。in.
  + "attribute vec4 a_Color;        \n"   // 我们将传入的每个顶点的颜色信息。
  + "varying vec4 v_Color;          \n"   // 这将传递到片段着色器中。
 
  + "void main()                    \n"   // 顶点着色器的入口点。
  + "{                              \n"
  + "   v_Color = a_Color;          \n"   // 将颜色传递给片段着色器。它将在三角形上进行插值。
  + "   gl_Position = u_MVPMatrix   \n"   // gl_Position 是一个特殊变量,用于存储最终位置。
  + "               * a_Position;   \n"   // 将顶点乘以矩阵,得到在标准化屏幕坐标中的最终点。
  + "}                              \n"; 

设置颜色之外,还告诉 OpenGL 顶点在屏幕上的最终位置。于是我们定义片段着色器

final String fragmentShader =
    "precision mediump float;       \n"     // 将默认精度设置为中等。在片段着色器中不需要太高的精度。
                                            
  + "varying vec4 v_Color;          \n"     // 这是从顶点着色器插值得到的三角形上每个片段的颜色。interpolated across the
                                           
  + "void main()                    \n"     // 片段着色器的入口。
  + "{                              \n"
  + "   gl_FragColor = v_Color;     \n"     // 直接通过管线传递颜色。
  + "} 
将着色器加载到 OpenGL

我们创建着色器对象。如果成功,我们将获得该对象的引用。然后我们用这个引用传入shader源码,然后我们编译它。

// 加载顶点着色器。
int vertexShaderHandle = GLES20.glCreateShader(GLES20.GL_VERTEX_SHADER);
 
if (vertexShaderHandle != 0)
{
    // 传入着色器源码。
    GLES20.glShaderSource(vertexShaderHandle, vertexShader);
 
    // 编译着色器。
    GLES20.glCompileShader(vertexShaderHandle);
 
    // 获取编译状态。
    final int[] compileStatus = new int[1];
    GLES20.glGetShaderiv(vertexShaderHandle, GLES20.GL_COMPILE_STATUS, compileStatus, 0);
 
    // 如果编译失败,删除着色器。
    if (compileStatus[0] == 0)
    {
        GLES20.glDeleteShader(vertexShaderHandle);
        vertexShaderHandle = 0;
    }
}
 
// 如果顶点着色器创建失败,抛出异常。
if (vertexShaderHandle == 0)
{
    throw new RuntimeException("Error creating vertex shader.");
}

链接着色器

在使用顶点和片段着色器之前,同样需要将它们绑定到一个程序中。这就是将顶点着色器的输出与片段着色器的输入连接起来的原因。因为我们将位置和颜色作为属性传入,因此我们需要绑定这些属性。然后我们将着色器链接在一起。

// 创建一个渲染程序并存储其引用。
int programHandle = GLES20.glCreateProgram();
 
if (programHandle != 0)
{
    // 将顶点着色器绑定到程序。
    GLES20.glAttachShader(programHandle, vertexShaderHandle);
 
    // 将片段着色器绑定到程序。
    GLES20.glAttachShader(programHandle, fragmentShaderHandle);
 
     // 绑定顶点属性的位置。
    GLES20.glBindAttribLocation(programHandle, 0, "a_Position");
    GLES20.glBindAttribLocation(programHandle, 1, "a_Color");
 
    // 将两个着色器链接到一个程序中。
    GLES20.glLinkProgram(programHandle);
 
    // 获取链接状态。
    final int[] linkStatus = new int[1];
    GLES20.glGetProgramiv(programHandle, GLES20.GL_LINK_STATUS, linkStatus, 0);
 
    // 如果链接失败,删除程序。
    if (linkStatus[0] == 0)
    {
        GLES20.glDeleteProgram(programHandle);
        programHandle = 0;
    }
}

// 如果程序创建失败,抛出异常。
if (programHandle == 0)
{
    throw new RuntimeException("Error creating program.");
}


// 新类成员
// 用于传递变换矩阵的句柄。
private int mMVPMatrixHandle;
 
// 用于传递模型位置信息的句柄。
private int mPositionHandle;
 
//  用于传递模型颜色信息的句柄。
private int mColorHandle;
 
@Override
public void onSurfaceCreated(GL10 glUnused, EGLConfig config)
{
    ...
 
    // 设置程序句柄。稍后将使用这些句柄将值传递到程序中。
    mMVPMatrixHandle = GLES20.glGetUniformLocation(programHandle, "u_MVPMatrix");
    mPositionHandle = GLES20.glGetAttribLocation(programHandle, "a_Position");
    mColorHandle = GLES20.glGetAttribLocation(programHandle, "a_Color");
 
    // 告诉OpenGL在渲染时使用这个程序。
    // 只使用一个程序,因此我们可以将其放在 onSurfaceCreated() 而不是 onDrawFrame() 
    GLES20.glUseProgram(programHandle);
}

设置透视投影

onSurfaceChanged() 至少被调用一次,并且每当我们的surfaceView发生变化时也会被调用。由于我们只需要在投影的屏幕发生变化时重置投影矩阵,因此 onSurfaceChanged() 是执行此操作的合适位置。

// 新类成员
// 存储投影矩阵。用于将场景投影到2D视口上。
private float[] mProjectionMatrix = new float[16];
 
@Override
public void onSurfaceChanged(GL10 glUnused, int width, int height)
{
    // 将OpenGL视口设置为与Surface相同的大小。
    GLES20.glViewport(0, 0, width, height);
 
    // 创建一个新的透视投影矩阵。高度将保持不变,而宽度将根据宽高比变化。
    final float ratio = (float) width / height;
    final float left = -ratio;
    final float right = ratio;
    final float bottom = -1.0f;
    final float top = 1.0f;
    final float near = 1.0f;
    final float far = 10.0f;
 	
	// 使用Matrix类的frustumM方法创建透视投影矩阵。
    Matrix.frustumM(mProjectionMatrix, 0, left, right, bottom, top, near, far);
}

绘制到屏幕

屏幕上实际显示内容的地方。首先清除屏幕,这样得到一个干净的屏幕,并且希望三角形能够平滑地动画,因此使用时间来旋转它们。每当您在屏幕上制作动画时,通常最好使用时间而不是帧速率。

// 新类成员
// 存储模型矩阵。此矩阵用于将模型从对象空间(每个模型可以被认为位于宇宙的中心)移动到世界空间。
private float[] mModelMatrix = new float[16];
 
@Override
public void onDrawFrame(GL10 glUnused)
{
	GLES20.glClear(GLES20.GL_DEPTH_BUFFER_BIT | GLES20.GL_COLOR_BUFFER_BIT);
 
	// 每10秒一次的完整旋转。
	long time = SystemClock.uptimeMillis() % 10000L;
	float angleInDegrees = (360.0f / 10000.0f) * ((int) time);
 
	// 绘制面向正前方的三角形。
	Matrix.setIdentityM(mModelMatrix, 0);
	Matrix.rotateM(mModelMatrix, 0, angleInDegrees, 0.0f, 0.0f, 1.0f);
	drawTriangle(mTriangle1Vertices);
 
	...
}

实际的绘制是在drawTriangle中完成的:

// 新类成员
// 为最终合并矩阵分配存储空间。这将传递到着色器程序中。
private float[] mMVPMatrix = new float[16];
 
// 每个顶点的元素数量。
private final int mStrideBytes = 7 * mBytesPerFloat;
 
// 位置数据的偏移量。
private final int mPositionOffset = 0;
 
// 位置数据元素的大小。
private final int mPositionDataSize = 3;
 
// 颜色数据的偏移量。
private final int mColorOffset = 3;
 
// 颜色数据元素的大小。
private final int mColorDataSize = 4;
 
/**
 * 绘制给定顶点数据的三角形。
 *
 * @param aTriangleBuffer 包含顶点数据的缓冲区。
 */
private void drawTriangle(final FloatBuffer aTriangleBuffer)
{
    // 传递位置信息
    aTriangleBuffer.position(mPositionOffset);
    GLES20.glVertexAttribPointer(mPositionHandle, mPositionDataSize, GLES20.GL_FLOAT, false,
            mStrideBytes, aTriangleBuffer);
 
    GLES20.glEnableVertexAttribArray(mPositionHandle);
 
    // 传递颜色信息
    aTriangleBuffer.position(mColorOffset);
    GLES20.glVertexAttribPointer(mColorHandle, mColorDataSize, GLES20.GL_FLOAT, false,
            mStrideBytes, aTriangleBuffer);
 
    GLES20.glEnableVertexAttribArray(mColorHandle);
 
    // 这将视图矩阵乘以模型矩阵,并将结果存储在mMVPMatrix中
    // (其中当前包含模型 * 视图)。
    Matrix.multiplyMM(mMVPMatrix, 0, mViewMatrix, 0, mModelMatrix, 0);
 
    // 这将模型视图矩阵乘以投影矩阵,并将结果存储在mMVPMatrix中
    // (其中当前包含模型 * 视图 * 投影)。
    Matrix.multiplyMM(mMVPMatrix, 0, mProjectionMatrix, 0, mMVPMatrix, 0);
 
    GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mMVPMatrix, 0);
    GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);
}

还记得我们最初创建渲染器时定义的那些缓冲区吗?我们终于能够使用它们了。我们需要使用 GLES20.glVertexAttribPointer() 告诉 OpenGL 如何使用这些数据。让我们看一下第一个调用:

// 传递位置信息
aTriangleBuffer.position(mPositionOffset);
GLES20.glVertexAttribPointer(mPositionHandle, mPositionDataSize, GLES20.GL_FLOAT, false,
        mStrideBytes, aTriangleBuffer);
GLES20.glEnableVertexAttribArray(mPositionHandle);

以上代码将缓冲区位置设置为位置偏移量,该位置位于缓冲区的开头。然后,我们告诉 OpenGL 使用此数据并将其输入顶点着色器,并将其应用到我们的位置属性。我们还需要告诉 OpenGL 每个顶点之间有多少个元素,或者步长。

重要:*步长需要以字节为单位定义。虽然顶点之间有 7 个元素(3 个表示位置,4 个表示颜色),但实际上有 28 个字节,因为每个浮点数占用 4 个字节。忘记此步骤可能不会导致任何错误,但是屏幕上看不到任何内容。*

最终结果

如果一切顺利, 你会看到下面屏幕截图的内容,回顾一下,我们学习了如何创建 OpenGL 上下文、传递形状数据、加载顶点和像素着色器、设置变换矩阵,最后将它们组合在一起。

这些三角形在我们可能是平平无奇的,但是我们已经了解了渲染管线的关键流程,如果有兴趣深入,你可以渲染出更加精美和酷炫的动画,访问www.shadertoy.com/, 可以学习到很多shader的写法.