Android OpenGL ES:着色器

230 阅读15分钟

Android OpenGL ES系列目录

本系列文章主要是基于LearnOpenGL和对应的中文教程。与原教程主要的差异是,该系列讲解的是基于Android设备环境的OpenGL ES,并提供对应的Java示例。

原文:Shaders

中文原文:着色器

OpenGL ES 着色语言 3.00 规范

着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说,着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。

现在我们会用一种更加广泛的形式详细解释着色器,特别是OpenGL着色器语言(GLSL)。

GLSL

着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。

着色器的开头总是要声明版本(GLSL ES 3.00以后才需要),接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。

GLSL ES 3.00以后的着色器,主要有以下结构:

#version version_number es
in type in_variable_name;
in type in_variable_name;

out type out_variable_name;

uniform type uniform_name;

int main()
{
  // 处理输入并进行一些图形操作
  ...
  // 输出处理过的结果到输出变量
  out_variable_name = weird_stuff_we_processed;
}

每个输入变量也叫顶点属性(Vertex Attribute)。我们能声明的顶点属性是有上限的,它一般由硬件来决定。OpenGL确保至少有16个包含4分量的顶点属性可用。可以通过查询GL_MAX_VERTEX_ATTRIBS来获取具体的上限:

int[] maxAttrCnt = new int[1];
GLES20.glGetIntegerv(GLES20.GL_MAX_VERTEX_ATTRIBS, maxAttrCnt, 0);
Log.d(TAG, "Maximum number of vertex attributes supported : " + maxAttrCnt[0]);

GLSL版本

文中的GLSL主要指GLSL ES 3.00。GLSL ES 1.00可参考GLSL 详解(基础篇) 和官方文档OpenGL ES 着色语言 1.00 规范

  • OpenGL ES 3.0以及和更高版本中,GLSL版本号和OpenGL ES的版本是匹配的。
  • OpenGL ES 2.x用的是GLSL ES 1.00。GLSL ES 1.00也可以在OpenGL ES 3.x中使用,这是兼容的。
  • OpenGL ES 1.x为固定渲染管线。不支持GLSL ES。
  • GLSL ES与GLSL:OpenGL ES的GLSL ES 3.00是从OpenGL的GLSL 3.3增删而来的。
OpenGL ES 版本GLSL ES版本
1.0--
1.1--
2.0100
3.0300
3.1310
3.2320

数据类型

GLSL的基本数据类型有:

  • int
  • uint(无符号的int类型)
  • float
  • bool(即Java中的boolean类型)

GLSL还有三种特殊类型:

  • 向量(Vector)
  • 矩阵(Matrix) (矩阵在后面的教程再讨论)
  • 采样器(Sampler) (采样器在后面的教程再讨论)

向量

GLSL中的向量是一个可以包含有2、3或者4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。它们可以是下面的形式(n代表分量的数量):

类型含义
vecn包含n个float分量的默认向量
bvecn包含n个bool分量的向量
ivecn包含n个int分量的向量
uvecn包含n个unsigned int分量的向量
dvecn包含n个double分量的向量

大多数时候我们使用vecn,因为float足够满足大多数要求了。

获取向量的分量

一个向量的分量可以通过vec.x这种方式获取。分别使用.x、.y、.z和.w可获取向量的第1、2、3、4个分量。GLSL也允许你对颜色使用rgba,或是对纹理坐标使用stpq访问相同的分量。

不允许在一个vec2向量中去获取.z分量,因为vec2只有x、y两个分量。

重组

向量这一数据类型也允许一些有趣而灵活的分量选择方式,叫做重组(Swizzling)。重组允许这样的语法:

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

可以使用原向量的任意分量的组合来创建新向量,只要原来向量有对应的分量即可。

我们也可以把一个向量作为一个参数传给不同的向量构造函数,以减少需求参数的数量:

vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);

输入与输出

虽然着色器是各自独立的小程序,但它们都是OpenGL图形渲染管线的一部分。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所以,通常每个着色器都有输入和输出,这样才能进行数据交流和传递。

GLSL定义了in和out关键字来标记输入、输出。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。但在顶点和片段着色器中会有点不同。

  • 顶点着色器的输入

    顶点着色器的输入特殊在于它需要从顶点数据中直接接收输入。为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量的位置。

    在前面的教程,我们已经知道,将顶点数据发送给GPU,需要通过glVertexAttribPointer来告知GPU该如何解析顶点数据。该方法的第一个参数是indx。我们在顶点着色器中使用了layout(location = 0),该操作把输入变量的位置值设置为了0。location等价于indx。

    顶点着色器需要为它的输入提供一个额外的layout标识,这样我们才能把它链接到顶点数据。

你也可以不使用layout (location = 0),通过在OpenGL代码中使用glGetAttribLocation查询属性位置值(Location)。然后传给glVertexAttribPointer

  • 片段着色器

    片段着色器需要一个vec4颜色输出变量。因为片段着色器需要生成一个最终输出的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。

所以,如果打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个对应的输入。当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了(这是在链接程序对象时完成的)。为了展示这是如何工作的,我们会稍微改动一下之前教程里的那个着色器,让顶点着色器为片段着色器决定颜色:

顶点着色器

#version 300 es
layout (location = 0) in vec3 aPos;//位置变量的属性位置值为0
out vec4 vertexColor;// 为片段着色器指定一个颜色输出,使用关键字out

void main() {
    gl_Position = vec4(aPos, 1.0);//把一个vec3作为vec4的构造器的参数
    vertexColor = vec4(0.5, 0.0, 0.0, 1.0);// 把输出变量设置为暗红色
}

片段着色器

#version 300 es
precision mediump float;
out vec4 FragColor;
in vec4 vertexColor;// 从顶点着色器传来的输入变量(名称相同、类型相同),使用关键字in

void main() {
    FragColor = vertexColor;
}

我们在顶点着色器中声明了一个vertexColor变量,并在片段着色器中声明了一个类似的vertexColor。由于它们名字相同且类型相同,一个标记为输入,一个标记为输出,所以片段着色器中的vertexColor就和顶点着色器中的vertexColor链接了。由于我们在顶点着色器中将颜色设置为深红色,最终的片段也是深红色的。效果如下:

渲染器代码:RedTriangleRender

顶点着色器代码:vertex_triangle_red

片段着色器代码:frag_triangle_red

Uniform

Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式。首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。

GLSL用uniform关键字来声明一个的uniform变量。下面通过uniform来设置三角形的颜色:

#version 300 es
precision mediump float;
out vec4 FragColor;
uniform vec4 ourColor;// 在OpenGL程序代码中设定这个变量

void main(){
    FragColor = ourColor;
}

我们在片段着色器中声明了一个uniform vec4的ourColor变量,并把片段着色器的输出颜色设置为ourColor。因为uniform是全局变量,所以我们可以在任何着色器中声明并使用它们,无需通过顶点着色器作为中介。

如果你声明了一个uniform却在GLSL代码中没用过,编译器会静默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致几个非常麻烦的错误,记住这点!

声明uniform变量后,接下来就是设置它的值:

GLES20.glUseProgram(shaderProgram);

long timeValue = System.currentTimeMillis() / 1000;
float greenValue = (float) (Math.sin(timeValue) / 2.0f + 0.5f);
int vertexColorLocation = GLES20.glGetUniformLocation(shaderProgram, "ourColor");
GLES20.glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

首先我们通过获取当前时间戳,然后使用sin函数让颜色在0.0到1.0之间改变,最后将结果储存到greenValue里。

接着,用glGetUniformLocation查询uniform vec4 ourColor的位置值,参数为着色器程序和uniform变量的名字。如果glGetUniformLocation返回-1就代表没有找到uniform的位置值。

最后,我们可以通过glUniform4f函数设置uniform值。注意,查询uniform地址不要求你之前使用过着色器程序,但是更新一个uniform之前你必须先调用glUseProgram激活对应的着色器程序。

由于OpenGL本质是一个C库,所以它不支持函数重载。在函数参数不同的时候就要为其定义新的函数。glUniform系列函数是一个典型例子。这系列函数有一个特定的后缀,标识uniform的类型。可能的后缀有:

后缀含义
f函数需要一个float作为它的值
i函数需要一个int作为它的值
ui函数需要一个unsigned int作为它的值
3f函数需要3个float作为它的值
fv函数需要一个float向量作为它的值

由于颜色一直变化,为简单起见,我们把渲染模式设为RENDERMODE_CONTINUOUSLY

setRenderMode(RENDERMODE_CONTINUOUSLY);

最终效果如下:

渲染器代码:DynamicColorTriangleRender

顶点着色器代码:vertex_rgb_triangle

精度限定符

前面在片段着色器里,出现了一行precision mediump float。这是精度的声明,格式如下:

precision precision-qualifier type;

highp、mediump和lowp

精度限定符(precision-qualifie)有三种:

  • highp:highp变量具有最大的范围和精度,但可能导致操作耗时增加。
  • mediump:mediump变量通常用于存储高位深的颜色和低精度的几何图。
  • lowp:lowp变量通常用于存储8-bit颜色值。

精度限定符的使用方法有两种:

  • 用于精度声明语句
  • 直接用于修饰变量:
lowp float color;
out mediump vec2 P;
lowp ivec2 foo(lowp mat3);
highp mat4 m;

type

精度声明语句中的type可以是floatint或者任意的sampler类型。如果是其他类型,将会导致着色器代码编译报错。下列声明均是错误的:

precision mediump bool;
precision mediump uint;
precision mediump mat4;
precision mediump vec4;

不过,还有两条补充的规则,能覆盖到大部分GLSL的数据类型:

  • 如果typefloat,则该声明会应用于非精确限定的浮点类型(scalar、vector和matrix)的声明。
  • 如果typeint,则该声明应用于所有非精确限定的整型((scalar、vector、signed和unsigned)声明。

补充规则的应用对象,包括所有无精度限定声明的全局变量声明、函数返回声明、函数参数声明和局部变量。这些对象,会使用作用域中最近的精度声明语句中指定的精度限定符。

精度声明语句的作用域和变量相同。同一类型的多个精度声明语句可以出现在同一作用域中,不过,后面的语句会覆盖该作用域中的前面的语句。

默认精度声明语句

顶点着色器预先声明了以下全局作用域的默认精度声明语句:

precision highp float;
precision highp int;
precision lowp sampler2D;
precision lowp samplerCube;

片段着色器预先声明了以下全局作用域的默认精度声明语句:

precision mediump int;
precision lowp sampler2D;
precision lowp samplerCube;

片段着色器没有float的默认精度限定符。因此,对于float、浮点vector和matrix的声明,要么声明必须包含精度限定符,要么必须预先声明了默认的float精度。所以这是为什么前面的片段着色器里,出现了一行precision mediump float

类似的,顶点着色器、片段着色器没有下面一些sampler类型的默认精度限定符

sampler3D;
samplerCubeShadow;
sampler2DShadow;
sampler2DArray;
sampler2DArrayShadow;
isampler2D;
isampler3D;
isamplerCube;
isampler2DArray;
usampler2D;
usampler3D;
usamplerCube;
usampler2DArray;

更多属性

现在,我们来把颜色属性加入顶点数据中。一个颜色属性用3个float值来表示。我们将把三角形的三个角分别指定为红色、绿色和蓝色,并添加至vertices数组:

float[] vertices = {
        // 位置              // 颜色
        0.5f , -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,   // 右下
        -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,   // 左下
        0.0f ,  0.5f, 0.0f, 0.0f, 0.0f, 1.0f    // 顶部
};

由于现在有更多的数据要发送到顶点着色器,我们有必要去调整一下顶点着色器,使它能够接收颜色值作为一个顶点属性输入。需要注意的是我们用layout标识符来把aColor属性的位置值设置为1:

顶点着色器:

#version 300 es
layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 ourColor; // 向片段着色器输出一个颜色

void main() {
    gl_Position = vec4(aPos, 1.0);
    ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

由于我们不是用uniform来传递片段的颜色了,现在是使用来自顶点着色器的ourColor变量,所以必须再修改一下片段着色器:

#version 300 es
out vec4 FragColor;
in vec3 ourColor;

void main() {
    FragColor = vec4(ourColor, 1.0);
}

因为我们添加了另一个顶点属性:颜色属性,所以我们必须更新glVertexAttribPointer,告知GPU它该如何解释内存中的顶点数据:位置属性 + 颜色属性。

现在内存中的顶点数据是这样的:

FloatBuffer verticesBuffer = ByteBuffer
                .allocateDirect(vertices.length * BYTES_PER_FLOAT)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer()
                .put(vertices);
verticesBuffer.position(0);
GLES20.glBufferData(GL_ARRAY_BUFFER, vertices.length * BYTES_PER_FLOAT, verticesBuffer, GL_STATIC_DRAW);

//设置位置属性
GLES20.glVertexAttribPointer(0, 3, GL_FLOAT, false, 6 * BYTES_PER_FLOAT, 0);
GLES20.glEnableVertexAttribArray(0);

//设置颜色属性
GLES20.glVertexAttribPointer(1, 3, GL_FLOAT, false, 6 * BYTES_PER_FLOAT, 3 * BYTES_PER_FLOAT);
GLES20.glEnableVertexAttribArray(1);

我们回顾一下glVertexAttribPointer函数,然后分析一下上述代码:

  • void glVertexAttribPointer(int indx, int size, int type, boolean normalized, int stride, int offset)

    告诉OpenGL该如何解析某一类型的顶点属性

    • indx:顶点属性的编号。
      • 例如一个Vertex可能有多个属性:位置、颜色、纹理等,分别编号为0、1、2。将indx设为对应的编号,就代表选中指定的属性。
      • 在顶点着色器中我们用layout(location = 0)定义位置属性的位置值,用layout (location = 1)定义颜色属性的位置值。而Location是等价于indx。所以在设置位置、颜色属性时,indx分别传01
    • size:指定顶点属性的组件数目。aPos是一个vec3,由3个值组成:X、Y、Z。
    • type:指定顶点属性组件的数据类型。GLSL的vec*都是浮点数组成的。所以这里是GL_FLOAT。
    • normalized:是否希望数据被标准化(Normalize)到[-1, 1](对有符号类型)或者[0, 1](对无符号类型)。
    • stride:指定两个领近的、类型相同的顶点属性的字节偏移。由于我们加入了颜色属性,如上图,从Vertex1的位置属性,到下一个位置属性,即Vertex2里的位置属性,我们必须向右移动6个float,其中3个是位置值,另外3个是颜色值。这使我们的stride为6乘以float的字节数,即24字节。
    • offset:指定当前设置的顶点属性的第一份数据在数组中的偏移量(Offset),单位字节。由于第一个位置属性就在数组的开头,所以偏移量是0个字节。而第一个颜色属性,紧跟在第一个位置属性之后,所以偏移量为3乘以float,即12个字节。

运行程序后结果如下:

这个图片可能不是你所期望的那样,因为我们只提供了3个颜色。

为什么?这是在片段着色器中进行的片段插值(Fragment Interpolation)的结果。当渲染一个三角形时,光栅化(Rasterization)阶段通常会造成比原指定顶点更多的片段。光栅会根据每个片段在三角形形状上所处相对位置决定这些片段的位置。

基于这些位置,它会插值(Interpolate)所有片段着色器的输入变量。比如说,我们绘制一条线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合。更精确地说就是30%蓝 + 70%绿。

这正是在这个三角形中发生了什么。虽然我们只提供了3个顶点和3个颜色,但从这个三角形的像素来看,它可能包含50000左右的片段。片段着色器为这些像素进行插值颜色。如果你仔细看这些颜色就应该能明白了:红首先变成到紫再变为蓝色。片段插值会被应用到片段着色器的所有输入属性上。

渲染器代码:RGBTriangleRender

顶点着色器代码:vertex_rgb_triangle

片段着色器代码:frag_rgb_triangle