Android OpenGL 开发2 - 顶点定义以及shader

上一篇教程中我们学习了如何开发一个非常简单的Android OpenGL应用,但是也正如在上篇结尾处提到的那样,我们没有画任何有意义的东西出来,在这一篇中,我们将解决该问题。我会基于上一篇教程的例子做相应修改,画一个三角形出来。

顶点定义

在我们的Renderer做渲染之前,我们首选需要有东西可以用来显示。在OpenGL(2.0及以后)中,我们需要将要显示的数据从应用中传递给GPU,这些数据就是顶点数据,openGL中顶点称之为Vertex,它可以表示位置信息,颜色信息以及其它任何我们需要的。

三点确定一个三角形。我们常常用“顶点”(Vertex,复数vertices)来指代3D图形学中的点。一个顶点有三个坐标:X,Y和Z。您可以这样想象这三根坐标轴:

按照约定,OpenGL是一个右手坐标系。最基本的就是说正x轴在你的右手边,正y轴往上而正z轴是往后的。想象你的屏幕处于三个轴的中心且正z轴穿过你的屏幕朝向你。坐标系画起来如下:

为了理解为什么被称为右手坐标系,按如下的步骤做:

  • 张开你的右手使正y轴沿着你的手往上。
  • 使你的大拇指往右。
  • 使你的食指往上。
  • 向下90度弯曲你的中指。

如果你都正确地做了,那么你的大拇指朝着正x轴方向,食指朝着正y轴方向,中指朝着正z轴方向。如果你用左手来做这些动作,你会发现z轴的方向是相反的。这就是有名的左手坐标系,它被DirectX广泛地使用。注意在标准化设备坐标系中OpenGL使用的是左手坐标系(投影矩阵改变了惯用手的习惯)。

所以我们今天要做的第一件事情就是先定义顶点坐标,OpenGL不是简单地把所有的3D坐标变换为屏幕上的2D像素;OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。所有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示。让我们以三角形为例, 代码比较简单,看看注释就明白了。

// 逆时针方向的定义三角形的三个顶点
private static float triangleCoords[] = {
        0.0f,  0.5f, 0.0f, // top
        -0.5f, -0.0f, 0.0f, // bottom left
        0.5f, -0.0f, 0.0f  // bottom right
};

// 顶点颜色,RGBA
private float color[] = { 1.0f, 0.0f, 0.0f, 1.0f }; // Red color

使数据能够被OpenGL访问到

顶点的坐标以及颜色定义好之后,下一步就是将这些信息传递给OpenGL做渲染了,但是问题来了 -- 我们的应用不能和OpenGL直接通信:

三角形的顶点数据定义在java层,他们运行在Android的虚拟机Davik中,这里面的代码是不能跳出Davik而去访问native层的。另外,这里面的数据有可能会在某个时候被GC回收掉。 这样设计的好处是应用开发者不需要考虑底层的CPU,memory或者其他硬件的具体情况,但是当我们需要开发openGL程序的时候问题就来了,OpenGL是直接跑在硬件上的,不是跑在Davik中的,没有virtual machine,没有GC。

那么如何使OpenGL能够访问到Java层的数据呢?

一种方式是通过JNI,事实上,当我们调用GLES的函数的时候,SDK就是通过JNI来调用native层的library的。

第二种方式,将Java中的memory heap拷贝到native memory heap中,进一步将就是在java中改变我们分配内存的方式。有一些java类就是直接在native层分配memory的,然后我们将数据拷贝到这样的memory中,native层就可以访问了,而且不受GC控制。

好了我们就采用这一种方式吧,一起来看代码:

private static final int BYTES_PER_FLOAT = 4;

private final FloatBuffer mVertexBuffer;
mVertexBuffer = ByteBuffer.allocateDirect(triangleCoords.length * BYTE_PER_FLOAT)
             .order(ByteOrder.nativeOrder())
             .asFloatBUffer();
mVertexBuffer(triangleCoords).position(0);

其中:

  • ByteBuffer.allocateDirec就是在native层申请memory的。
  • ByteOrder.nativeOrder() 涉及到大端小端模式,我们要求采用和底层一致的模式。
  • asFloatBUffer 我们不想按字节操作,而是希望操纵float数据
  • vertexData.put(tableVertices); 用来将数据从Dalvik的memory拷贝到native的memory。

这一步看起来或许有些费解,但是请记住,这是在向openGL传递数据前必须的一步。

现在,我们已经准备好向openGL传递数据了。

着色器(Shader)

在OpenGL中,任何事物都在3D空间中,而屏幕和窗口却是2D像素数组,这导致OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素。3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线(Graphics Pipeline,大多译为管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程)管理的。图形渲染管线可以被划分为两个主要部分:第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。

图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。

有些着色器允许开发者自己配置,这就允许我们用自己写的着色器来替换默认的。这样我们就可以更细致地控制图形渲染管线中的特定部分了,而且因为它们运行在GPU上,所以它们可以给我们节约宝贵的CPU时间。

下面,你会看到一个图形渲染管线的每个阶段的抽象展示。要注意蓝色部分代表的是我们可以注入自定义的着色器的部分。

可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置顶点和片段着色器就行了。几何着色器是可选的,通常使用它默认的着色器就行了。

在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。出于这个原因,刚开始学习现代OpenGL的时候可能会非常困难,因为在你能够渲染自己的第一个三角形之前已经需要了解一大堆知识了。在本节结束你最终渲染出你的三角形的时候,你也会了解到非常多的图形编程知识。

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

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

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

虽然着色器是各自独立的小程序,但是它们都是一个整体的一部分,出于这样的原因,我们希望每个着色器都有输入和输出,这样才能进行数据交流和传递。GLSL定义了in和out关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。但在顶点和片段着色器中会有点不同。

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

下面是我们今天要用到的两个shader文件:

Vertex shader(basic_vertex_shader.glsl):

1
2
3
4
5
6
attribute vec4 vPosition;

void main() {
gl_Position = vPosition;

}

Fragment shader(basic_fragment_shader.glsl):

1
2
3
4
5
6
precision mediump float;
uniform vec4 vColor;

void main() {
gl_FragColor = vColor;
}

加载shader

在写好shader文件之后,我们下一步就是将其加载到内存中来。比较简单,直接上代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class ShaderHelper {
public static int buildProgram(String vertexShaderSource, String fragmentShaderSource) {
int program;

int vertexShader = compileVertexShader(vertexShaderSource);
int fragmentShader = compileFragmentShader(fragmentShaderSource);

program = linkProgram(vertexShader,fragmentShader);

return program;
}

public static int compileVertexShader(String shaderCode) {
return compileShader(GLES20.GL_VERTEX_SHADER, shaderCode);
}

public static int compileFragmentShader(String shaderCode) {return compileShader(GLES20.GL_FRAGMENT_SHADER, shaderCode);}

private static int compileShader(int type, String shaderCode) {
final int shaderObjectId = GLES20.glCreateShader(type);
if(shaderObjectId == 0) {
throw new RuntimeException(" Could not create shader object");
}

GLES20.glShaderSource(shaderObjectId,shaderCode);

GLES20.glCompileShader(shaderObjectId);

final int[] compileStatus = new int[1];

GLES20.glGetShaderiv(shaderObjectId, GLES20.GL_COMPILE_STATUS, compileStatus, 0);


if (compileStatus[0] == 0)
{
GLES20.glDeleteShader(shaderObjectId);
throw new RuntimeException(" Could not compile shader ");
}

return shaderObjectId;
}

public static int linkProgram(int vertextShaderId, int fragmentShaderId) {
final int programObjectId = GLES20.glCreateProgram();

if(programObjectId == 0) {
throw new RuntimeException("Could not create Program");
}

GLES20.glAttachShader(programObjectId, vertextShaderId);
GLES20.glAttachShader(programObjectId, fragmentShaderId);

GLES20.glLinkProgram(programObjectId);

// Get the link status.
final int[] linkStatus = new int[1];
GLES20.glGetProgramiv(programObjectId, GLES20.GL_LINK_STATUS, linkStatus, 0);

// If the link failed, delete the program.
if (linkStatus[0] == 0)
{
GLES20.glDeleteProgram(programObjectId);
throw new RuntimeException("could not link program ");
}

return programObjectId;
}

}

封装OpenGL三角形

接下来我们需要将定义好的三角形顶点坐标传递给shder,从而将其画出来,为了逻辑清楚,我们将之前的定义三角形顶点坐标,以及调用shader,传递数据到shader都放到一个单独的类里面GLTriangle,当需要画的时候调用这个类就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class GLTriangle {
private static final String TAG = GLTriangle.class.getSimpleName();
private Context mContext;
private FloatBuffer mVertexBuffer;
private int mProgram;
private int mPositionHandle;
private int mColorHandle;

private final int vertexCount = triangleCoords.length / COORDS_PER_VERTEX;
private final int vertexStride = COORDS_PER_VERTEX * 4; // 4 bytes per vertex

// number of coordinates per vertex in this array
private static final int COORDS_PER_VERTEX = 3;

private static final int BYTES_PER_FLOAT = 4;

private static float triangleCoords[] = { // in counterclockwise order:
0.0f, 0.5f, 0.0f, // top
-0.5f, -0.0f, 0.0f, // bottom left
0.5f, -0.0f, 0.0f // bottom right
};

// Set color with red, green, blue and alpha (opacity) values
private float color[] = { 1.0f, 0.0f, 0.0f, 1.0f };


public GLTriangle(Context context) {
Log.d(TAG, "GLTriangle: vertexCount = " + vertexCount);
mContext = context;
initData();
initShader(context);
}

private void initData() {
mVertexBuffer = ByteBuffer.allocateDirect(triangleCoords.length * BYTES_PER_FLOAT)
.order(ByteOrder.nativeOrder()) // use the device hardware's native byte order
.asFloatBuffer(); // create a floating point buffer from the ByteBuffer
mVertexBuffer.put(triangleCoords).position(0); // add the coordinates to the FloatBuffer and set the buffer to read the first coordinate
}

private void initShader(Context context) {
mProgram = ShaderHelper.buildProgram(Utils.readTextFileFromResource(mContext, R.raw.basic_vertex_shader),
Utils.readTextFileFromResource(mContext, R.raw.basic_fragment_shader));
mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition");
// get handle to fragment shader's vColor member
mColorHandle = GLES20.glGetUniformLocation(mProgram, "vColor");
}

public void draw() {
// Add program to OpenGL ES environment
GLES20.glUseProgram(mProgram);

// get handle to vertex shader's vPosition member
mPositionHandle = GLES20.glGetAttribLocation(mProgram, "vPosition");

// Enable a handle to the triangle vertices
GLES20.glEnableVertexAttribArray(mPositionHandle);

// Prepare the triangle coordinate data
GLES20.glVertexAttribPointer(mPositionHandle, COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
vertexStride, mVertexBuffer);

// get handle to fragment shader's vColor member
mColorHandle = GLES20.glGetUniformLocation(mProgram, "vColor");

// Set color for drawing the triangle
GLES20.glUniform4fv(mColorHandle, 1, color, 0);

// Draw the triangle
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, vertexCount);
}

}

画三角形

最后一步,我们修改在上一篇教程中的MyGLRender, 使其调用我们刚刚封装好的GLTriangle来画三角形:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class MyGLRender implements GLSurfaceView.Renderer {
private GLTriangle mTriangle;
private Context mContext;

private BasicShaderProgram mBasicShaderProgram;
private ColorShaderProgram mColorShaderProgram;

public MyGLRender(Context context) {
mContext = context;
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {

GLES20.glClearColor(1.0f, 1.0f, 1.0f, 0.0f);

mTriangle = new GLTriangle(mContext);
}

@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {

GLES20.glViewport(0, 0, width, height);

}

@Override
public void onDrawFrame(GL10 gl) {

GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
GLES20.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);

mTriangle.draw();
}
}

总结

这一篇文章中包含的概念比较多,如果有问题的话,建议仔细学习一下OpenGL的pipeline,shader等概念,毕竟这是现代OpenGL的基础。

Reference